Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions codersdk/aibridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -418,6 +422,7 @@ type GroupAIBudget struct {
}

type UpsertGroupAIBudgetRequest struct {
// SpendLimitMicros must not exceed MaxAISpendLimitMicros.
SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not enforce this? (lte)

Unless we want to configure this later on?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is enforced, just in the handler rather than the tag. A lte tag can't reference the constant, so the number would be duplicated (maintenance drift), and the generated message doesn't name the maximum. It would show something like

Validation failed for tag "lte" with value: "2000000000000"

While the handler returns Must not exceed 1000000000000. instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah fair enough

}

Expand Down Expand Up @@ -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"`
Comment thread
ssncferreira marked this conversation as resolved.
}

// UserAIBudgetOverride returns the AI spend budget override configured for the given user.
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/api/schemas.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions enterprise/coderd/aibridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions enterprise/coderd/aibridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
13 changes: 13 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 14 additions & 6 deletions site/src/modules/groups.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
35 changes: 27 additions & 8 deletions site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { maxAIBudgetDollars } from "#/modules/groups";
import { MockGroup } from "#/testHelpers/entities";
import GroupSettingsPageView from "./GroupSettingsPageView";

Expand Down Expand Up @@ -40,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.");
},
};

Expand Down Expand Up @@ -91,10 +90,30 @@ 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.");
},
};

// 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");

await userEvent.type(input, String(maxAIBudgetDollars + 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();
},
};

Expand Down
14 changes: 10 additions & 4 deletions site/src/pages/GroupsPage/GroupSettingsPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import {
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import { isEveryoneGroup } from "#/modules/groups";
import {
aiBudgetRangeError,
isEveryoneGroup,
maxAIBudgetDollars,
} from "#/modules/groups";
import { usdBudgetFormatter } from "#/utils/currency";
import {
getFormHelpers,
Expand All @@ -34,10 +38,11 @@ type FormData = {
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, aiBudgetRangeError)
.max(maxAIBudgetDollars, aiBudgetRangeError),
});

interface AIBudgetFeedbackProps {
Expand Down Expand Up @@ -94,7 +99,7 @@ const AIBudgetFeedback: FC<AIBudgetFeedbackProps> = ({
<span className="font-medium text-content-primary">
{usdBudgetFormatter.format(budgetAmount * memberCount)}
</span>
/month maximum, based on{" "}
/month, based on{" "}
<span className="font-medium text-content-primary">{memberCount}</span>{" "}
{memberCount === 1 ? "member" : "members"}.
</span>
Expand Down Expand Up @@ -244,6 +249,7 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
onBlur={budgetField.onBlur}
type="number"
min="0"
max={maxAIBudgetDollars}
step="1"
placeholder="unlimited"
aria-invalid={budgetField.error}
Expand Down
Loading
Loading