diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 13eea683db2..6f9843821fe 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a50b9dabee9..59bfe973780 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index ff3fcff369b..3215bed8b3c 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -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 diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index cff040db9af..88026e8599f 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -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 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 582c2051ee6..80023c6ccc6 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 5b6f256e68a..be338982c0f 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -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. diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 38e467f0c60..7410f9279c1 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -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() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 0a6118214da..d65dd1f6950 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -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)) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4567c34e078..543a5d9dd86 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 { /**