feat(coderd): resolve effective user AI budget (#26142)

Closes
https://linear.app/codercom/issue/AIGOV-287/add-effective-group-resolution

Implements the effective AI budget resolution from the AI Governance
cost-controls RFC: for a given user, a `user_ai_budget_overrides` row
wins if present, otherwise the deployment budget policy (`highest`)
picks the largest group budget across the user's groups, ties broken
alphabetically.

For now, I keep the logic under `coderd/aibridge/budget`, but that may
change during the implementation of budget enforcement.
This commit is contained in:
Yevhenii Shcherbina
2026-06-10 14:01:20 +00:00
committed by GitHub
parent f9dfa18c46
commit 1cada0649c
9 changed files with 384 additions and 0 deletions
+7
View File
@@ -3625,6 +3625,13 @@ func (q *querier) GetHealthSettings(ctx context.Context) (string, error) {
return q.db.GetHealthSettings(ctx)
}
func (q *querier) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) {
if _, err := q.GetUserByID(ctx, userID); err != nil { // AuthZ check
return database.GetHighestGroupAIBudgetByUserRow{}, err
}
return q.db.GetHighestGroupAIBudgetByUser(ctx, userID)
}
func (q *querier) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
return fetchWithAction(q.log, q.auth, policy.ActionRead, q.db.GetInboxNotificationByID)(ctx, id)
}
+8
View File
@@ -6502,6 +6502,14 @@ func (s *MethodTestSuite) TestAIBridge() {
check.Args(user.ID).Asserts(user, policy.ActionRead).Returns(override)
}))
s.Run("GetHighestGroupAIBudgetByUser", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
row := testutil.Fake(s.T(), faker, database.GetHighestGroupAIBudgetByUserRow{})
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
dbm.EXPECT().GetHighestGroupAIBudgetByUser(gomock.Any(), user.ID).Return(row, nil).AnyTimes()
check.Args(user.ID).Asserts(user, policy.ActionRead).Returns(row)
}))
s.Run("UpsertUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
group := testutil.Fake(s.T(), faker, database.Group{})
+8
View File
@@ -2001,6 +2001,14 @@ func (m queryMetricsStore) GetHealthSettings(ctx context.Context) (string, error
return r0, r1
}
func (m queryMetricsStore) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) {
start := time.Now()
r0, r1 := m.s.GetHighestGroupAIBudgetByUser(ctx, userID)
m.queryLatencies.WithLabelValues("GetHighestGroupAIBudgetByUser").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetHighestGroupAIBudgetByUser").Inc()
return r0, r1
}
func (m queryMetricsStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
start := time.Now()
r0, r1 := m.s.GetInboxNotificationByID(ctx, id)
+15
View File
@@ -3720,6 +3720,21 @@ func (mr *MockStoreMockRecorder) GetHealthSettings(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHealthSettings", reflect.TypeOf((*MockStore)(nil).GetHealthSettings), ctx)
}
// GetHighestGroupAIBudgetByUser mocks base method.
func (m *MockStore) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetHighestGroupAIBudgetByUser", ctx, userID)
ret0, _ := ret[0].(database.GetHighestGroupAIBudgetByUserRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetHighestGroupAIBudgetByUser indicates an expected call of GetHighestGroupAIBudgetByUser.
func (mr *MockStoreMockRecorder) GetHighestGroupAIBudgetByUser(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHighestGroupAIBudgetByUser", reflect.TypeOf((*MockStore)(nil).GetHighestGroupAIBudgetByUser), ctx, userID)
}
// GetInboxNotificationByID mocks base method.
func (m *MockStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
m.ctrl.T.Helper()
+7
View File
@@ -503,6 +503,13 @@ type sqlcQuerier interface {
// A limit of 0 means "no limit".
GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error)
GetHealthSettings(ctx context.Context) (string, error)
// Returns the highest group AI budget across the groups the user belongs to,
// breaking ties by group name ascending. Implements the "highest" budget policy.
// group_members_expanded is a UNION of group_members and organization_members,
// so the implicit "Everyone" group (group_id == organization_id) is included.
// Returns no rows when the user has no budgeted groups; callers should treat
// sql.ErrNoRows as "no group budget".
GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (GetHighestGroupAIBudgetByUserRow, error)
GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (InboxNotification, error)
// Fetches inbox notifications for a user filtered by templates and targets
// param user_id: The user ID
+35
View File
@@ -2617,6 +2617,41 @@ func (q *sqlQuerier) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (G
return i, err
}
const getHighestGroupAIBudgetByUser = `-- name: GetHighestGroupAIBudgetByUser :one
SELECT
gaib.group_id,
gaib.spend_limit_micros
FROM group_ai_budgets gaib
JOIN group_members_expanded gme ON gme.group_id = gaib.group_id
WHERE gme.user_id = $1
ORDER BY
gaib.spend_limit_micros DESC, -- highest wins
gme.group_name ASC, -- alphabetical tiebreak
-- Final tiebreak on the group id makes the result deterministic when two
-- groups share both name and limit, which is possible across organizations
-- (groups are unique on (organization_id, name), not name alone).
gaib.group_id ASC
LIMIT 1
`
type GetHighestGroupAIBudgetByUserRow struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"`
}
// Returns the highest group AI budget across the groups the user belongs to,
// breaking ties by group name ascending. Implements the "highest" budget policy.
// group_members_expanded is a UNION of group_members and organization_members,
// so the implicit "Everyone" group (group_id == organization_id) is included.
// Returns no rows when the user has no budgeted groups; callers should treat
// sql.ErrNoRows as "no group budget".
func (q *sqlQuerier) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (GetHighestGroupAIBudgetByUserRow, error) {
row := q.db.QueryRowContext(ctx, getHighestGroupAIBudgetByUser, userID)
var i GetHighestGroupAIBudgetByUserRow
err := row.Scan(&i.GroupID, &i.SpendLimitMicros)
return i, err
}
const getUserAIBudgetOverride = `-- name: GetUserAIBudgetOverride :one
SELECT user_id, group_id, spend_limit_micros, created_at, updated_at
FROM user_ai_budget_overrides
+22
View File
@@ -57,3 +57,25 @@ RETURNING *;
-- name: DeleteUserAIBudgetOverride :one
DELETE FROM user_ai_budget_overrides WHERE user_id = @user_id RETURNING *;
-- name: GetHighestGroupAIBudgetByUser :one
-- Returns the highest group AI budget across the groups the user belongs to,
-- breaking ties by group name ascending. Implements the "highest" budget policy.
-- group_members_expanded is a UNION of group_members and organization_members,
-- so the implicit "Everyone" group (group_id == organization_id) is included.
-- Returns no rows when the user has no budgeted groups; callers should treat
-- sql.ErrNoRows as "no group budget".
SELECT
gaib.group_id,
gaib.spend_limit_micros
FROM group_ai_budgets gaib
JOIN group_members_expanded gme ON gme.group_id = gaib.group_id
WHERE gme.user_id = @user_id
ORDER BY
gaib.spend_limit_micros DESC, -- highest wins
gme.group_name ASC, -- alphabetical tiebreak
-- Final tiebreak on the group id makes the result deterministic when two
-- groups share both name and limit, which is possible across organizations
-- (groups are unique on (organization_id, name), not name alone).
gaib.group_id ASC
LIMIT 1;