feat: add group AI spend endpoint (#27568)

## Description

Adds `GET /api/v2/groups/{group}/ai/spend`, returning the AI spend limit
and aggregate spend for a single group over the current budget period.
The period is derived from the deployment's configured budget period
rather than being caller-specified, matching the other AI spend
endpoints.

## Changes

- Add the `groupAISpend` handler and route, gated by the
`aigateway-cost-control` experiment and the `AIBridge` feature.
- Reuse the existing `GetOrganizationGroupsAISpend` query with a single
group ID, so no new query or authorization path is introduced.
- Add the `GroupAISpend` codersdk type and client method.

Closes
https://linear.app/codercom/issue/AIGOV-475/implement-apiv2groupsgroupaispend

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by
@ssncferreira
This commit is contained in:
Susana Ferreira
2026-07-28 11:32:51 +01:00
committed by GitHub
parent e83f018f5f
commit ed37483ff7
9 changed files with 526 additions and 0 deletions
+63
View File
@@ -3400,6 +3400,42 @@ const docTemplate = `{
]
}
},
"/api/v2/groups/{group}/ai/spend": {
"get": {
"description": "Returns the AI spend limit and aggregate spend for the group.",
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get group AI spend",
"operationId": "get-group-ai-spend",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Group ID",
"name": "group",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GroupAISpend"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/groups/{group}/members": {
"get": {
"produces": [
@@ -20642,6 +20678,33 @@ const docTemplate = `{
}
}
},
"codersdk.GroupAISpend": {
"type": "object",
"properties": {
"current_spend_micros": {
"description": "CurrentSpendMicros is the group's spend over the current budget\nperiod.",
"type": "integer"
},
"group_id": {
"type": "string",
"format": "uuid"
},
"period_end": {
"description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.",
"type": "string",
"format": "date-time"
},
"period_start": {
"description": "PeriodStart is the inclusive lower bound of the current budget\nperiod.",
"type": "string",
"format": "date-time"
},
"spend_limit_micros": {
"description": "SpendLimitMicros is the group's configured AI spend limit. Null when\nthe group has no configured budget.",
"type": "integer"
}
}
},
"codersdk.GroupMemberAISpend": {
"type": "object",
"properties": {
+59
View File
@@ -2999,6 +2999,38 @@
]
}
},
"/api/v2/groups/{group}/ai/spend": {
"get": {
"description": "Returns the AI spend limit and aggregate spend for the group.",
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get group AI spend",
"operationId": "get-group-ai-spend",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Group ID",
"name": "group",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GroupAISpend"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/groups/{group}/members": {
"get": {
"produces": ["application/json"],
@@ -18790,6 +18822,33 @@
}
}
},
"codersdk.GroupAISpend": {
"type": "object",
"properties": {
"current_spend_micros": {
"description": "CurrentSpendMicros is the group's spend over the current budget\nperiod.",
"type": "integer"
},
"group_id": {
"type": "string",
"format": "uuid"
},
"period_end": {
"description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.",
"type": "string",
"format": "date-time"
},
"period_start": {
"description": "PeriodStart is the inclusive lower bound of the current budget\nperiod.",
"type": "string",
"format": "date-time"
},
"spend_limit_micros": {
"description": "SpendLimitMicros is the group's configured AI spend limit. Null when\nthe group has no configured budget.",
"type": "integer"
}
}
},
"codersdk.GroupMemberAISpend": {
"type": "object",
"properties": {
+26
View File
@@ -92,6 +92,13 @@ type OrganizationGroupAISpend struct {
CurrentSpendMicros int64 `json:"current_spend_micros"`
}
// GroupAISpend is the current AI spend snapshot for a single group within
// the active budget period.
type GroupAISpend struct {
AISpendPeriodWindow
OrganizationGroupAISpend
}
// GroupMembersAISpend reports per-member AI spend attributed to a specific
// group in the active budget period.
type GroupMembersAISpend struct {
@@ -577,6 +584,25 @@ func (c *Client) OrganizationGroupsAISpend(ctx context.Context, organization uui
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GroupAISpend returns AI spend for the given group within the active budget
// period.
func (c *Client) GroupAISpend(ctx context.Context, group uuid.UUID) (GroupAISpend, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/groups/%s/ai/spend", group.String()),
nil,
)
if err != nil {
return GroupAISpend{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GroupAISpend{}, ReadBodyAsError(res)
}
var resp GroupAISpend
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GroupMembersAISpend returns AI spend attributed to the given group for the
// specified users within the active budget period. At most 100 user IDs may be
// requested per call, and callers with more members are expected to batch
+43
View File
@@ -1035,6 +1035,49 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group}/ai/budget \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get group AI spend
### Code samples
```sh
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/groups/{group}/ai/spend \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /api/v2/groups/{group}/ai/spend`
Returns the AI spend limit and aggregate spend for the group.
### Parameters
| Name | In | Type | Required | Description |
|---------|------|--------------|----------|-------------|
| `group` | path | string(uuid) | true | Group ID |
### Example responses
> 200 Response
```json
{
"current_spend_micros": 0,
"group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
"period_end": "2019-08-24T14:15:22Z",
"period_start": "2019-08-24T14:15:22Z",
"spend_limit_micros": 0
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GroupAISpend](schemas.md#codersdkgroupaispend) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get group members by group ID
### Code samples
+22
View File
@@ -7750,6 +7750,28 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `spend_limit_micros` | integer | false | | |
| `updated_at` | string | false | | |
## codersdk.GroupAISpend
```json
{
"current_spend_micros": 0,
"group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
"period_end": "2019-08-24T14:15:22Z",
"period_start": "2019-08-24T14:15:22Z",
"spend_limit_micros": 0
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------|
| `current_spend_micros` | integer | false | | Current spend micros is the group's spend over the current budget period. |
| `group_id` | string | false | | |
| `period_end` | string | false | | Period end is the exclusive upper bound of the current budget period. |
| `period_start` | string | false | | Period start is the inclusive lower bound of the current budget period. |
| `spend_limit_micros` | integer | false | | Spend limit micros is the group's configured AI spend limit. Null when the group has no configured budget. |
## codersdk.GroupMemberAISpend
```json
+52
View File
@@ -1232,6 +1232,58 @@ func (api *API) exportOrganizationAISpend(rw http.ResponseWriter, r *http.Reques
}
}
// @Summary Get group AI spend
// @Description Returns the AI spend limit and aggregate spend for the group.
// @ID get-group-ai-spend
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param group path string true "Group ID" format(uuid)
// @Success 200 {object} codersdk.GroupAISpend
// @Router /api/v2/groups/{group}/ai/spend [get]
func (api *API) groupAISpend(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
group := httpmw.GroupParam(r)
logger := api.Logger.With(slog.F("group_id", group.ID))
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: group.OrganizationID,
GroupIds: []uuid.UUID{group.ID},
PeriodStart: periodWindow.Start,
})
if err != nil {
logger.Error(ctx, "failed to get group AI spend", slog.Error(err))
httpapi.InternalServerError(rw, err)
return
}
// Read access was already established when the group was extracted from
// the route, so the query only returns no rows when the group was deleted
// in between.
if len(rows) == 0 {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupAISpend{
AISpendPeriodWindow: codersdk.AISpendPeriodWindow{
PeriodStart: periodWindow.Start,
PeriodEnd: periodWindow.End,
},
OrganizationGroupAISpend: db2sdk.OrganizationGroupAISpend(rows[0]),
})
}
// @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.
+243
View File
@@ -4570,6 +4570,249 @@ func TestExportOrganizationAISpend(t *testing.T) {
})
}
func TestGroupAISpend(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-spend-group",
})
require.NoError(t, err)
_, err = adminClient.GroupAISpend(ctx, group.ID)
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-spend-group",
})
require.NoError(t, err)
_, err = adminClient.GroupAISpend(ctx, group.ID)
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("MalformedGroupID", func(t *testing.T) {
t.Parallel()
adminClient, _, _ := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "malformed-group-id-spend-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: a malformed UUID in the path.
// When: querying spend.
res, err := adminClient.Request(ctx, http.MethodGet, "/api/v2/groups/not-a-uuid/ai/spend", nil)
require.NoError(t, err)
defer res.Body.Close()
// Then: 400.
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("UnknownGroup", func(t *testing.T) {
t.Parallel()
adminClient, _, _ := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "unknown-group-spend-group"})
ctx := testutil.Context(t, testutil.WaitLong)
// Given: a group ID that does not exist.
// When: querying spend.
_, err := adminClient.GroupAISpend(ctx, uuid.New())
// Then: request fails with 404.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
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: "group-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.GroupAISpend(ctx, group.ID)
require.NoError(t, err)
// Then: the response reports the expected budget and spend.
require.Equal(t, wantPeriodStart, got.PeriodStart)
require.Equal(t, wantPeriodEnd, got.PeriodEnd)
require.Equal(t, group.ID, got.GroupID)
require.Equal(t, tt.wantSpendLimit, got.SpendLimitMicros)
require.Equal(t, tt.wantCurrentSpend, got.CurrentSpendMicros)
})
}
}
func TestGroupAISpendRoleAccess(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: "group-spend-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.GroupAISpend(ctx, 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.Equal(t, group.ID, resp.GroupID)
})
}
}
func TestExportOrganizationAISpendRoleAccess(t *testing.T) {
t.Parallel()
+9
View File
@@ -633,6 +633,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
)
r.Get("/", api.groupMembersAISpend)
})
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.groupAISpend)
})
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))
+9
View File
@@ -5212,6 +5212,15 @@ export interface GroupAIBudget {
readonly updated_at: string;
}
// From codersdk/aibridge.go
/**
* GroupAISpend is the current AI spend snapshot for a single group within
* the active budget period.
*/
export interface GroupAISpend
extends AISpendPeriodWindow,
OrganizationGroupAISpend {}
// From codersdk/groups.go
export interface GroupArguments {
/**