feat: add GET /organizations/{org}/groups/ai/spend (#27123)

## Description

Adds `GET /api/v2/organizations/{org}/groups/ai/spend?group_ids=...` to return per-group AI spend and configured limits for a set of groups in an organization.

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

- Different concepts stay on different endpoints: identity (groups) 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/organizations/{org}/groups` → returns the organization's groups.
2. Request `/api/v2/organizations/{org}/groups/ai/spend?group_ids=...` with the IDs from step 1.

The groups endpoint from 1) is currently not paginated, but if pagination is added later, this design keeps the two responses in sync. This spend endpoint intentionally takes `group_ids` rather than paginating on its own, since it depends on the group set from step 1. Pagination could be added in the future, especially for Cost Control-focused pages.

<img width="2880" height="1460" alt="image" src="https://github.com/user-attachments/assets/ea83b74d-6a4f-45a6-af2f-1024e019da07" />

## Changes

- Add `codersdk.OrganizationGroupsAISpend` and `OrganizationGroupAISpend` types, plus a shared `AISpendPeriodWindow` embedded in the spend response.
- Add `GetOrganizationGroupsAISpend` SQL query with a dbauthz per-row filter that mirrors `GET /organizations/{org}/groups`.
- Add handler and route under `/organizations/{organization}/groups/ai/spend` with a required `group_ids` query param (cap 100). Callers with more than 100 groups 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-466/backend-organization-groups-endpoint-with-groups-spend

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
This commit is contained in:
Susana Ferreira
2026-07-20 12:54:52 +01:00
committed by GitHub
parent b511a68ab0
commit 2adc8f5272
19 changed files with 1216 additions and 23 deletions
+88 -5
View File
@@ -36,7 +36,8 @@ const (
defaultListClientsLimit = 100
// aiBridgeRateLimitWindow is the fixed duration for rate limiting AI Bridge
// requests. This is hardcoded to keep configuration simple.
aiBridgeRateLimitWindow = time.Second
aiBridgeRateLimitWindow = time.Second
maxOrganizationGroupsAISpendGroupIDs = 100
)
// errInvalidCursor is returned when a pagination cursor does not
@@ -877,6 +878,13 @@ func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque
rw.WriteHeader(http.StatusNoContent)
}
// currentAIBudgetWindow returns the current AI budget period window based on
// the configured budget period.
func (api *API) currentAIBudgetWindow() (budget.PeriodWindow, error) {
period := codersdk.NewAIBudgetPeriodFromString(api.DeploymentValues.AI.BridgeConfig.BudgetPeriod)
return budget.CurrentPeriod(api.Clock.Now(), period)
}
// @Summary Get user AI spend
// @ID get-user-ai-spend
// @Security CoderSessionToken
@@ -890,8 +898,7 @@ func (api *API) userAISpendStatus(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
logger := api.Logger.With(slog.F("user_id", user.ID))
period := codersdk.NewAIBudgetPeriodFromString(api.DeploymentValues.AI.BridgeConfig.BudgetPeriod)
periodWindow, err := budget.CurrentPeriod(api.Clock.Now(), period)
periodWindow, err := api.currentAIBudgetWindow()
if err != nil {
logger.Error(ctx, "failed to compute AI budget period", slog.Error(err))
httpapi.InternalServerError(rw, err)
@@ -914,8 +921,10 @@ func (api *API) userAISpendStatus(rw http.ResponseWriter, r *http.Request) {
UserAIBudgetSummary: codersdk.UserAIBudgetSummary{
UserID: user.ID,
},
PeriodStart: periodWindow.Start,
PeriodEnd: periodWindow.End,
AISpendPeriodWindow: codersdk.AISpendPeriodWindow{
PeriodStart: periodWindow.Start,
PeriodEnd: periodWindow.End,
},
}
if ok {
@@ -939,3 +948,77 @@ func (api *API) userAISpendStatus(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get organization groups AI spend
// @Description Returns AI spend limits and aggregate spend for the requested groups.
// @Description A maximum of 100 group IDs may be requested per call, and requests with more are rejected, so callers are expected to batch across multiple requests.
// @Description Unknown or unreadable group IDs are silently omitted.
// @ID get-organization-groups-ai-spend
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param organization path string true "Organization ID" format(uuid)
// @Param group_ids query string true "Comma-separated list of group IDs (maximum 100)"
// @Success 200 {object} codersdk.OrganizationGroupsAISpend
// @Router /api/v2/organizations/{organization}/groups/ai/spend [get]
func (api *API) organizationGroupsAISpend(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
org := httpmw.OrganizationParam(r)
logger := api.Logger.With(slog.F("organization_id", org.ID))
parser := httpapi.NewQueryParamParser()
parser.RequiredNotEmpty("group_ids")
groupIDs := parser.UUIDs(r.URL.Query(), nil, "group_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(groupIDs) > maxOrganizationGroupsAISpendGroupIDs {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf(
"group_ids has %d entries, maximum is %d.",
len(groupIDs), maxOrganizationGroupsAISpendGroupIDs,
),
})
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.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
OrganizationID: org.ID,
GroupIds: groupIDs,
PeriodStart: periodWindow.Start,
})
if err != nil {
logger.Error(ctx, "failed to get organization groups AI spend", slog.Error(err))
httpapi.InternalServerError(rw, err)
return
}
resp := codersdk.OrganizationGroupsAISpend{
AISpendPeriodWindow: codersdk.AISpendPeriodWindow{
PeriodStart: periodWindow.Start,
PeriodEnd: periodWindow.End,
},
Groups: make([]codersdk.OrganizationGroupAISpend, 0, len(rows)),
}
for _, row := range rows {
resp.Groups = append(resp.Groups, db2sdk.OrganizationGroupAISpend(row))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
+308
View File
@@ -3206,6 +3206,314 @@ func TestUserAISpendStatusRoleAccess(t *testing.T) {
}
}
func TestOrganizationGroupsAISpend(t *testing.T) {
t.Parallel()
t.Run("RequiresLicenseFeature", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)}
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{DeploymentValues: dv},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is irrelevant here; the request is blocked before RBAC.
_, err := client.OrganizationGroupsAISpend(ctx, owner.OrganizationID, []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)
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{DeploymentValues: dv},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureAIBridge: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is irrelevant here; the request is blocked before RBAC.
_, err := client.OrganizationGroupsAISpend(ctx, owner.OrganizationID, []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("MissingGroupIDs", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "missing-ids-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: no group_ids query parameter.
// When: querying spend.
_, err := adminClient.OrganizationGroupsAISpend(ctx, group.OrganizationID, nil)
// Then: request fails with 400.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("InclusiveMaxGroupIDs", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "inclusive-max-group-ids-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: 100 group_ids, exactly at the cap.
ids := make([]uuid.UUID, 100)
for i := range ids {
ids[i] = uuid.New()
}
// When: querying spend.
_, err := adminClient.OrganizationGroupsAISpend(ctx, group.OrganizationID, ids)
// Then: request succeeds.
require.NoError(t, err)
})
t.Run("TooManyGroupIDs", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "too-many-group-ids-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: 101 group_ids, above the cap of 100.
ids := make([]uuid.UUID, 101)
for i := range ids {
ids[i] = uuid.New()
}
// When: querying spend.
_, err := adminClient.OrganizationGroupsAISpend(ctx, group.OrganizationID, ids)
// Then: request fails with 400.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("MalformedGroupID", func(t *testing.T) {
t.Parallel()
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "malformed-group-id-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: a malformed UUID passed via raw HTTP.
// When: querying spend.
res, err := adminClient.Request(ctx, http.MethodGet,
"/api/v2/organizations/"+group.OrganizationID.String()+"/groups/ai/spend",
nil,
func(r *http.Request) {
q := r.URL.Query()
q.Set("group_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("GroupInOtherOrgExcluded", func(t *testing.T) {
t.Parallel()
// Given: two groups, one in the queried org and one in a different org.
db, ps := dbtestutil.NewDB(t)
adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{
GroupName: "primary-org-group",
Database: db,
Pubsub: ps,
})
otherOrg := dbgen.Organization(t, db, database.Organization{})
otherOrgGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrg.ID})
ctx := testutil.Context(t, testutil.WaitLong)
// When: querying the primary org with both group IDs.
resp, err := adminClient.OrganizationGroupsAISpend(ctx, group.OrganizationID, []uuid.UUID{group.ID, otherOrgGroup.ID})
require.NoError(t, err)
// Then: only the primary-org group is returned.
require.Len(t, resp.Groups, 1)
require.Equal(t, group.ID, resp.Groups[0].GroupID)
})
tests := []struct {
name string
setBudget bool
spendLimit int64
spent int64
wantSpendLimit *int64
wantCurrentSpend int64
}{
{
name: "NoBudgetNoSpend",
},
{
name: "ZeroLimitBudget",
setBudget: true,
spendLimit: 0,
wantSpendLimit: ptr.Ref(int64(0)),
wantCurrentSpend: 0,
},
{
name: "BudgetZeroSpend",
setBudget: true,
spendLimit: 1_000_000_000,
wantSpendLimit: ptr.Ref(int64(1_000_000_000)),
wantCurrentSpend: 0,
},
{
name: "BudgetWithSpend",
setBudget: true,
spendLimit: 1_000_000_000,
spent: 250_000_000,
wantSpendLimit: ptr.Ref(int64(1_000_000_000)),
wantCurrentSpend: 250_000_000,
},
{
name: "SpendExceedsLimit",
setBudget: true,
spendLimit: 1_000_000_000,
spent: 1_500_000_000,
wantSpendLimit: ptr.Ref(int64(1_000_000_000)),
wantCurrentSpend: 1_500_000_000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Given: an admin, a group, and optionally a budget and seeded spend.
clock := quartz.NewMock(t)
db, ps := dbtestutil.NewDB(t)
adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{
GroupName: "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.setBudget {
_, err := adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{
SpendLimitMicros: tt.spendLimit,
})
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 spend.
got, err := adminClient.OrganizationGroupsAISpend(ctx, group.OrganizationID, []uuid.UUID{group.ID})
require.NoError(t, err)
// Then: the response contains one row with the expected fields.
require.Equal(t, wantPeriodStart, got.PeriodStart)
require.Equal(t, wantPeriodEnd, got.PeriodEnd)
require.Len(t, got.Groups, 1)
require.Equal(t, group.ID, got.Groups[0].GroupID)
require.Equal(t, tt.wantSpendLimit, got.Groups[0].SpendLimitMicros)
require.Equal(t, tt.wantCurrentSpend, got.Groups[0].CurrentSpendMicros)
})
}
}
func TestOrganizationGroupsAISpendRoleAccess(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, _ := 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-group",
})
require.NoError(t, err)
cases := []struct {
name string
client *codersdk.Client
wantGroup bool
}{
{name: "Owner", client: ownerClient, wantGroup: true},
{name: "UserAdmin", client: userAdminClient, wantGroup: true},
{name: "OrgAdmin", client: orgAdminClient, wantGroup: true},
{name: "OrgUserAdmin", client: orgUserAdminClient, wantGroup: true},
{name: "Member", client: memberClient, wantGroup: true},
{name: "OtherOrgMember", client: otherOrgMemberClient, wantGroup: 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.OrganizationGroupsAISpend(ctx, owner.OrganizationID, []uuid.UUID{group.ID})
if !tc.wantGroup {
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.Groups, 1)
require.Equal(t, group.ID, resp.Groups[0].GroupID)
})
}
}
// 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).
+9
View File
@@ -503,6 +503,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
)
r.Post("/", api.postGroupByOrganization)
r.Get("/", api.groupsByOrganization)
r.Route("/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.organizationGroupsAISpend)
})
r.Route("/{groupName}", func(r chi.Router) {
r.Use(
httpmw.ExtractGroupByNameParam(api.Database),