feat: add GET /groups/{group}/members/ai/spend (#27130)

## Description

Adds `GET /api/v2/groups/{group}/members/ai/spend?user_ids=...` (also available org-scoped at `/api/v2/organizations/{org}/groups/{groupName}/members/ai/spend`) to return per-member AI spend attributed to a group, along with each member's effective budget group and the applied spend limit when the queried group is their effective budget source.

In the UI, this endpoint is used alongside the existing `/api/v2/groups/{group}/members` endpoint. AI spend data is kept separate from that endpoint so that:

- Different concepts stay on different endpoints: identity (group members) vs. cost control (spend). Cost control is an additional feature layered on top of groups/orgs.
- Callers that don't need spend information don't pay for its computation.

UI flow:

1. Request `/api/v2/groups/{group}/members` → returns the group's members.
2. Request `/api/v2/groups/{group}/members/ai/spend?user_ids=...` with the IDs from step 1.

**Note:** Only current members of the queried group are returned. `spend_limit_micros` and `limit_source` are populated only when the queried group is the member's effective budget source (its own limit or a user override). `effective_group_id` is null when the member's budget resolves to a group in another organization, since an organization is treated as a tenant boundary.

<img width="2880" height="1904" alt="image" src="https://github.com/user-attachments/assets/33ed395d-d1a3-4b46-bb04-c8d3f41c8886" />

## Changes

- Add `codersdk.GroupMembersAISpend` and `GroupMemberAISpend` types, reusing the shared `AISpendPeriodWindow`.
- Add `GetGroupMembersAISpend` SQL query with a dbauthz per-row filter that mirrors `GET /api/v2/groups/{group}/members`.
- Add handler and routes under `/groups/{group}/members/ai/spend` (and the org-scoped alias) with a required `user_ids` query param (cap 100). Callers with more than 100 members are expected to batch across multiple requests.
- Add codersdk client method.
- Tests: dbauthz, raw SQL, endpoint, and role-access.

Closes https://linear.app/codercom/issue/AIGOV-471/backend-group-members-endpoint-with-members-spend

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
This commit is contained in:
Susana Ferreira
2026-07-20 13:04:37 +01:00
committed by GitHub
parent 2adc8f5272
commit a9fdf87a2f
20 changed files with 2009 additions and 0 deletions
+92
View File
@@ -38,6 +38,7 @@ const (
// requests. This is hardcoded to keep configuration simple.
aiBridgeRateLimitWindow = time.Second
maxOrganizationGroupsAISpendGroupIDs = 100
maxGroupMembersAISpendUserIDs = 100
)
// errInvalidCursor is returned when a pagination cursor does not
@@ -1022,3 +1023,94 @@ func (api *API) organizationGroupsAISpend(rw http.ResponseWriter, r *http.Reques
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get group members AI spend by organization
// @Description Returns aggregate AI spend attributed to the group per requested user.
// @Description A maximum of 100 user IDs may be requested per call, and requests with more are rejected, so callers are expected to batch across multiple requests.
// @Description User IDs that are not members of the group, or that the caller has no read access to, are silently omitted.
// @ID get-group-members-ai-spend-by-organization
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param organization path string true "Organization ID" format(uuid)
// @Param groupName path string true "Group name"
// @Param user_ids query string true "Comma-separated list of user IDs (maximum 100)"
// @Success 200 {object} codersdk.GroupMembersAISpend
// @Router /api/v2/organizations/{organization}/groups/{groupName}/members/ai/spend [get]
func (api *API) groupMembersAISpendByOrganization(rw http.ResponseWriter, r *http.Request) {
api.groupMembersAISpend(rw, r)
}
// @Summary Get group members AI spend
// @Description Returns aggregate AI spend attributed to the group per requested user.
// @Description A maximum of 100 user IDs may be requested per call, and requests with more are rejected, so callers are expected to batch across multiple requests.
// @Description User IDs that are not members of the group, or that the caller has no read access to, are silently omitted.
// @ID get-group-members-ai-spend
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param group path string true "Group ID" format(uuid)
// @Param user_ids query string true "Comma-separated list of user IDs (maximum 100)"
// @Success 200 {object} codersdk.GroupMembersAISpend
// @Router /api/v2/groups/{group}/members/ai/spend [get]
func (api *API) groupMembersAISpend(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
group := httpmw.GroupParam(r)
logger := api.Logger.With(slog.F("group_id", group.ID))
parser := httpapi.NewQueryParamParser()
parser.RequiredNotEmpty("user_ids")
userIDs := parser.UUIDs(r.URL.Query(), nil, "user_ids")
parser.ErrorExcessParams(r.URL.Query())
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
if len(userIDs) > maxGroupMembersAISpendUserIDs {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf(
"user_ids has %d entries, maximum is %d.",
len(userIDs), maxGroupMembersAISpendUserIDs,
),
})
return
}
periodWindow, err := api.currentAIBudgetWindow()
if err != nil {
logger.Error(ctx, "failed to compute AI budget period", slog.Error(err))
httpapi.InternalServerError(rw, err)
return
}
logger = logger.With(
slog.F("period_start", periodWindow.Start),
slog.F("period_end", periodWindow.End),
)
rows, err := api.Database.GetGroupMembersAISpend(ctx, database.GetGroupMembersAISpendParams{
GroupID: group.ID,
UserIds: userIDs,
PeriodStart: periodWindow.Start,
})
if err != nil {
logger.Error(ctx, "failed to get group members AI spend", slog.Error(err))
httpapi.InternalServerError(rw, err)
return
}
resp := codersdk.GroupMembersAISpend{
AISpendPeriodWindow: codersdk.AISpendPeriodWindow{
PeriodStart: periodWindow.Start,
PeriodEnd: periodWindow.End,
},
Members: make([]codersdk.GroupMemberAISpend, 0, len(rows)),
}
for _, row := range rows {
resp.Members = append(resp.Members, db2sdk.GroupMemberAISpend(row))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
+444
View File
@@ -3514,6 +3514,450 @@ func TestOrganizationGroupsAISpendRoleAccess(t *testing.T) {
}
}
func TestGroupMembersAISpend(t *testing.T) {
t.Parallel()
t.Run("RequiresLicenseFeature", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)}
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{DeploymentValues: dv},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
ctx := testutil.Context(t, testutil.WaitLong)
group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
Name: "req-license-feature-members-group",
})
require.NoError(t, err)
_, err = adminClient.GroupMembersAISpend(ctx, group.ID, []uuid.UUID{uuid.New()})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "AI Gateway is a Premium feature")
})
t.Run("RequiresExperiment", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{DeploymentValues: dv},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureAIBridge: 1,
},
},
})
adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
ctx := testutil.Context(t, testutil.WaitLong)
group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
Name: "req-experiment-members-group",
})
require.NoError(t, err)
_, err = adminClient.GroupMembersAISpend(ctx, group.ID, []uuid.UUID{uuid.New()})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "ai-gateway-cost-control")
})
t.Run("MissingUserIDs", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "missing-ids-members-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// When: querying with no user_ids.
_, err := adminClient.GroupMembersAISpend(ctx, group.ID, nil)
// Then: request fails with 400.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("InclusiveMaxUserIDs", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "inclusive-max-user-ids-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: 100 user_ids, exactly at the cap.
ids := make([]uuid.UUID, 100)
for i := range ids {
ids[i] = uuid.New()
}
// When: querying spend.
_, err := adminClient.GroupMembersAISpend(ctx, group.ID, ids)
// Then: request succeeds.
require.NoError(t, err)
})
t.Run("TooManyUserIDs", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "too-many-user-ids-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: 101 user_ids, above the cap of 100.
ids := make([]uuid.UUID, 101)
for i := range ids {
ids[i] = uuid.New()
}
// When: querying spend.
_, err := adminClient.GroupMembersAISpend(ctx, group.ID, ids)
// Then: request fails with 400.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("MalformedUserID", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "malformed-user-id-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: a malformed UUID passed via raw HTTP.
res, err := adminClient.Request(ctx, http.MethodGet,
"/api/v2/groups/"+group.ID.String()+"/members/ai/spend",
nil,
func(r *http.Request) {
q := r.URL.Query()
q.Set("user_ids", "not-a-uuid")
r.URL.RawQuery = q.Encode()
},
)
require.NoError(t, err)
defer res.Body.Close()
// Then: 400.
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("UserInOtherOrgExcluded", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{
GroupName: "primary-org-members-group",
Database: db,
Pubsub: ps,
})
otherOrg := dbgen.Organization(t, db, database.Organization{})
otherOrgUser := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: otherOrgUser.ID, OrganizationID: otherOrg.ID})
ctx := testutil.Context(t, testutil.WaitLong)
// When: querying the group with a user from the primary org and one from another org.
resp, err := adminClient.GroupMembersAISpend(ctx, group.ID, []uuid.UUID{targetUser.ID, otherOrgUser.ID})
require.NoError(t, err)
// Then: only the primary-org user is returned.
require.Len(t, resp.Members, 1)
require.Equal(t, targetUser.ID, resp.Members[0].UserID)
require.Nil(t, resp.Members[0].EffectiveGroupID)
require.Nil(t, resp.Members[0].GroupBudget)
require.Equal(t, int64(0), resp.Members[0].GroupSpendMicros)
})
tests := []struct {
name string
groupLimit int64
overrideLimit int64
spent int64
wantEffectiveGroup bool
wantGroupBudget *codersdk.AIGroupBudget
wantSpendMicros int64
}{
{
name: "NoBudgetNoSpend",
},
{
name: "BudgetZeroSpend",
groupLimit: 1_000_000_000,
wantEffectiveGroup: true,
wantGroupBudget: &codersdk.AIGroupBudget{
SpendLimitMicros: 1_000_000_000,
LimitSource: codersdk.AIBudgetLimitSourceGroup,
},
},
{
name: "BudgetWithSpend",
groupLimit: 1_000_000_000,
spent: 250_000_000,
wantEffectiveGroup: true,
wantGroupBudget: &codersdk.AIGroupBudget{
SpendLimitMicros: 1_000_000_000,
LimitSource: codersdk.AIBudgetLimitSourceGroup,
},
wantSpendMicros: 250_000_000,
},
{
name: "NoBudgetWithSpend",
spent: 100_000_000,
wantSpendMicros: 100_000_000,
},
{
name: "OverrideBudget",
overrideLimit: 500_000_000,
wantEffectiveGroup: true,
wantGroupBudget: &codersdk.AIGroupBudget{
SpendLimitMicros: 500_000_000,
LimitSource: codersdk.AIBudgetLimitSourceUserOverride,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Given: an admin, a group with a member, optionally a group budget,
// a user override, and seeded spend attributed to the group.
clock := quartz.NewMock(t)
db, ps := dbtestutil.NewDB(t)
adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{
GroupName: "members-spend-test-group",
Clock: clock,
Database: db,
Pubsub: ps,
})
ctx := testutil.Context(t, testutil.WaitLong)
clock.Set(time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC))
wantPeriodStart := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
wantPeriodEnd := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
if tt.groupLimit > 0 {
_, err := adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{
SpendLimitMicros: tt.groupLimit,
})
require.NoError(t, err)
}
if tt.overrideLimit > 0 {
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
GroupID: group.ID,
SpendLimitMicros: tt.overrideLimit,
})
require.NoError(t, err)
}
if tt.spent > 0 {
_, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
UserID: targetUser.ID,
EffectiveGroupID: group.ID,
Day: clock.Now(),
CostMicros: tt.spent,
})
require.NoError(t, err)
}
// When: querying the group's member spend.
got, err := adminClient.GroupMembersAISpend(ctx, group.ID, []uuid.UUID{targetUser.ID})
require.NoError(t, err)
// Then: one row is returned with the expected fields.
require.Equal(t, wantPeriodStart, got.PeriodStart)
require.Equal(t, wantPeriodEnd, got.PeriodEnd)
require.Len(t, got.Members, 1)
require.Equal(t, targetUser.ID, got.Members[0].UserID)
if tt.wantEffectiveGroup {
require.NotNil(t, got.Members[0].EffectiveGroupID)
require.Equal(t, group.ID, *got.Members[0].EffectiveGroupID)
} else {
require.Nil(t, got.Members[0].EffectiveGroupID)
}
require.Equal(t, tt.wantGroupBudget, got.Members[0].GroupBudget)
require.Equal(t, tt.wantSpendMicros, got.Members[0].GroupSpendMicros)
})
}
t.Run("CrossOrgEffectiveGroupMasked", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)}
db, ps := dbtestutil.NewDB(t)
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{DeploymentValues: dv, Database: db, Pubsub: ps},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureAIBridge: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
ctx := testutil.Context(t, testutil.WaitLong)
// Given: a member of the queried group whose highest-limit budget
// group lives in a different org.
otherOrg := coderdenttest.CreateOrganization(t, ownerClient, coderdenttest.CreateOrganizationOptions{})
_, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: targetUser.ID, OrganizationID: otherOrg.ID})
queried, err := userAdminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
Name: "queried-cross-org-mask-group",
})
require.NoError(t, err)
_, err = userAdminClient.PatchGroup(ctx, queried.ID, codersdk.PatchGroupRequest{
AddUsers: []string{targetUser.ID.String()},
})
require.NoError(t, err)
crossOrgGroup, err := userAdminClient.CreateGroup(ctx, otherOrg.ID, codersdk.CreateGroupRequest{
Name: "cross-org-budget-group",
})
require.NoError(t, err)
_, err = userAdminClient.PatchGroup(ctx, crossOrgGroup.ID, codersdk.PatchGroupRequest{
AddUsers: []string{targetUser.ID.String()},
})
require.NoError(t, err)
_, err = userAdminClient.UpsertGroupAIBudget(ctx, crossOrgGroup.ID, codersdk.UpsertGroupAIBudgetRequest{
SpendLimitMicros: 9_999_999,
})
require.NoError(t, err)
// When: the owner, who can read both orgs, queries the primary group.
//nolint:gocritic // The test asserts that even an owner sees the mask.
resp, err := ownerClient.GroupMembersAISpend(ctx, queried.ID, []uuid.UUID{targetUser.ID})
require.NoError(t, err)
// Then: effective_group_id is nil, even though the caller can read both orgs.
require.Len(t, resp.Members, 1)
require.Equal(t, targetUser.ID, resp.Members[0].UserID)
require.Nil(t, resp.Members[0].EffectiveGroupID, "cross-org effective group must be masked even for the owner")
require.Nil(t, resp.Members[0].GroupBudget)
require.Equal(t, int64(0), resp.Members[0].GroupSpendMicros)
})
t.Run("OrgScopedRoute", func(t *testing.T) {
t.Parallel()
adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "org-scoped-route-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// When: hitting the org-scoped alias route via raw HTTP.
res, err := adminClient.Request(ctx, http.MethodGet,
"/api/v2/organizations/"+group.OrganizationID.String()+"/groups/"+group.Name+"/members/ai/spend",
nil,
func(r *http.Request) {
q := r.URL.Query()
q.Set("user_ids", targetUser.ID.String())
r.URL.RawQuery = q.Encode()
},
)
require.NoError(t, err)
defer res.Body.Close()
// Then: 200 with the target user in the response.
require.Equal(t, http.StatusOK, res.StatusCode)
var got codersdk.GroupMembersAISpend
require.NoError(t, json.NewDecoder(res.Body).Decode(&got))
require.Len(t, got.Members, 1)
require.Equal(t, targetUser.ID, got.Members[0].UserID)
require.Nil(t, got.Members[0].EffectiveGroupID)
require.Nil(t, got.Members[0].GroupBudget)
require.Equal(t, int64(0), got.Members[0].GroupSpendMicros)
})
}
func TestGroupMembersAISpendRoleAccess(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)}
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{DeploymentValues: dv},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureAIBridge: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
orgUserAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgUserAdmin(owner.OrganizationID))
memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
_, otherMember := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
otherOrg := coderdenttest.CreateOrganization(t, ownerClient, coderdenttest.CreateOrganizationOptions{})
otherOrgMemberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, otherOrg.ID)
ctx := testutil.Context(t, testutil.WaitLong)
group, err := userAdminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
Name: "role-access-members-group",
})
require.NoError(t, err)
_, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
AddUsers: []string{member.ID.String(), otherMember.ID.String()},
})
require.NoError(t, err)
cases := []struct {
name string
client *codersdk.Client
wantMember bool
}{
{name: "Owner", client: ownerClient, wantMember: true},
{name: "UserAdmin", client: userAdminClient, wantMember: true},
{name: "OrgAdmin", client: orgAdminClient, wantMember: true},
{name: "OrgUserAdmin", client: orgUserAdminClient, wantMember: true},
{name: "Member", client: memberClient, wantMember: true},
{name: "OtherOrgMember", client: otherOrgMemberClient, wantMember: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := tc.client.GroupMembersAISpend(ctx, group.ID, []uuid.UUID{member.ID})
if !tc.wantMember {
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
return
}
require.NoError(t, err)
require.Len(t, resp.Members, 1)
require.Equal(t, member.ID, resp.Members[0].UserID)
})
}
t.Run("MemberCanOnlyReadOwnRow", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// When: a member queries spend for themselves and another member.
resp, err := memberClient.GroupMembersAISpend(ctx, group.ID, []uuid.UUID{member.ID, otherMember.ID})
require.NoError(t, err)
// Then: only the caller's own row is returned.
require.Len(t, resp.Members, 1)
require.Equal(t, member.ID, resp.Members[0].UserID)
})
}
// aiCostControlTestOptions configures the setup of an AI cost control test
// deployment. GroupName is required. Clock, Database, and Pubsub are
// optional overrides (leave nil for defaults).
+18
View File
@@ -519,6 +519,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Get("/", api.groupByOrganization)
r.Get("/members", api.groupMembersByOrganization)
r.Route("/members/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
r.Use(
// TODO(AIGOV-443): remove once AI Gateway cost control functionality is stable.
httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentAIGatewayCostControl),
api.RequireFeatureMW(codersdk.FeatureAIBridge),
)
r.Get("/", api.groupMembersAISpendByOrganization)
})
})
})
r.Route("/provisionerkeys", func(r chi.Router) {
@@ -604,6 +613,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Patch("/", api.patchGroup)
r.Delete("/", api.deleteGroup)
r.Get("/members", api.groupMembers)
r.Route("/members/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
r.Use(
// TODO(AIGOV-443): remove once AI Gateway cost control functionality is stable.
httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentAIGatewayCostControl),
api.RequireFeatureMW(codersdk.FeatureAIBridge),
)
r.Get("/", api.groupMembersAISpend)
})
r.Route("/ai/budget", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))