diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4c2c5b818dc23..7a8e946c35a90 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -357,6 +357,71 @@ const docTemplate = `{ } } }, + "/api/experimental/organizations/{organization}/ai/spend": { + "get": { + "description": "Returns the AI spend of the organization from the daily spend aggregate: the number of users with spend, the total and median spend per user, the spend for each UTC day, and one page of users, most spend first, with the groups their spend was attributed to. The count, totals, and daily spend cover every user, not only the returned page.\nThe optional period_start and period_end query parameters bound the period. They must be provided together, fall at UTC midnight, and span at most 31 days. When both are omitted, the current UTC monthly period is used.\nAn explicit period_start must fall within the configured AI Gateway data retention window, matching the per-user report. The default period is narrowed to that window instead, starting at the next UTC day. The response echoes the applied period, but every value covers it only through the current day, since later days have no spend yet.\nUnknown query parameters are rejected.\nRequires organization-level administrator permissions.", + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get organization AI spend overview", + "operationId": "get-organization-ai-spend-overview", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Inclusive lower bound at UTC midnight (RFC3339)", + "name": "period_start", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Exclusive upper bound at UTC midnight (RFC3339)", + "name": "period_end", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 10, maximum 100)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverview" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/ai/spend/users": { "get": { "description": "Returns one page of per-user AI spend for the organization, most expensive first, built from the same raw AI Gateway token usage as the CSV export so the two reconcile. Each user lists the providers and clients they spent through, and the response carries the user count, total spend, and unpriced usage count over every matching user.\nThe optional period_start and period_end query parameters bound the period and are interpreted as UTC. They must be provided together and span at most 31 days. When both are omitted, the current UTC monthly period is used.\nAn explicit period_start must fall within the configured AI Gateway data retention window, since older token usage is purged. The default period is narrowed to that window instead. The response echoes the applied bounds and, when retention is enabled, the start of the retention window.\nThe optional provider_name, model, and client query parameters restrict the spend report to usage matching all supplied filters. Use client=Unknown for usage with an unknown or missing client.\nUnknown query parameters are rejected.\nRequires organization-level administrator permissions.", @@ -28976,6 +29041,122 @@ const docTemplate = `{ } } }, + "codersdk.OrganizationAISpendBucket": { + "type": "object", + "properties": { + "end": { + "type": "string", + "format": "date-time" + }, + "spend_micros": { + "type": "integer" + }, + "start": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.OrganizationAISpendOverview": { + "type": "object", + "properties": { + "count": { + "description": "Count is the number of users with spend in the period.", + "type": "integer" + }, + "daily": { + "description": "Daily has one bucket per UTC day from the period start through the\ncurrent day. Days without spend are zero.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationAISpendBucket" + } + }, + "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" + }, + "retention_start": { + "description": "RetentionStart is the oldest instant for which token usage is still\nretained. An explicit period must not start before it. Omitted when the\ndeployment does not purge AI Gateway data.", + "type": "string", + "format": "date-time" + }, + "totals": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverviewTotals" + }, + "users": { + "description": "Users is the requested page, most spend first.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverviewUser" + } + } + } + }, + "codersdk.OrganizationAISpendOverviewGroup": { + "type": "object", + "properties": { + "group_display_name": { + "type": "string" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "group_name": { + "type": "string" + }, + "spend_micros": { + "type": "integer" + } + } + }, + "codersdk.OrganizationAISpendOverviewTotals": { + "type": "object", + "properties": { + "median_spend_micros": { + "description": "MedianSpendMicros is the median spend per user. Zero when no user has\nspend.", + "type": "integer" + }, + "spend_micros": { + "description": "SpendMicros is the spend of every user in the period.", + "type": "integer" + } + } + }, + "codersdk.OrganizationAISpendOverviewUser": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "groups": { + "description": "Groups are the groups the user's spend was attributed to, most spend\nfirst.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverviewGroup" + } + }, + "name": { + "type": "string" + }, + "spend_micros": { + "type": "integer" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, "codersdk.OrganizationAISpendReport": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c9eeefb7bdbe6..94506e255c15a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -308,6 +308,67 @@ } } }, + "/api/experimental/organizations/{organization}/ai/spend": { + "get": { + "description": "Returns the AI spend of the organization from the daily spend aggregate: the number of users with spend, the total and median spend per user, the spend for each UTC day, and one page of users, most spend first, with the groups their spend was attributed to. The count, totals, and daily spend cover every user, not only the returned page.\nThe optional period_start and period_end query parameters bound the period. They must be provided together, fall at UTC midnight, and span at most 31 days. When both are omitted, the current UTC monthly period is used.\nAn explicit period_start must fall within the configured AI Gateway data retention window, matching the per-user report. The default period is narrowed to that window instead, starting at the next UTC day. The response echoes the applied period, but every value covers it only through the current day, since later days have no spend yet.\nUnknown query parameters are rejected.\nRequires organization-level administrator permissions.", + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get organization AI spend overview", + "operationId": "get-organization-ai-spend-overview", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Inclusive lower bound at UTC midnight (RFC3339)", + "name": "period_start", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Exclusive upper bound at UTC midnight (RFC3339)", + "name": "period_end", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 10, maximum 100)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverview" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/ai/spend/users": { "get": { "description": "Returns one page of per-user AI spend for the organization, most expensive first, built from the same raw AI Gateway token usage as the CSV export so the two reconcile. Each user lists the providers and clients they spent through, and the response carries the user count, total spend, and unpriced usage count over every matching user.\nThe optional period_start and period_end query parameters bound the period and are interpreted as UTC. They must be provided together and span at most 31 days. When both are omitted, the current UTC monthly period is used.\nAn explicit period_start must fall within the configured AI Gateway data retention window, since older token usage is purged. The default period is narrowed to that window instead. The response echoes the applied bounds and, when retention is enabled, the start of the retention window.\nThe optional provider_name, model, and client query parameters restrict the spend report to usage matching all supplied filters. Use client=Unknown for usage with an unknown or missing client.\nUnknown query parameters are rejected.\nRequires organization-level administrator permissions.", @@ -26550,6 +26611,122 @@ } } }, + "codersdk.OrganizationAISpendBucket": { + "type": "object", + "properties": { + "end": { + "type": "string", + "format": "date-time" + }, + "spend_micros": { + "type": "integer" + }, + "start": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.OrganizationAISpendOverview": { + "type": "object", + "properties": { + "count": { + "description": "Count is the number of users with spend in the period.", + "type": "integer" + }, + "daily": { + "description": "Daily has one bucket per UTC day from the period start through the\ncurrent day. Days without spend are zero.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationAISpendBucket" + } + }, + "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" + }, + "retention_start": { + "description": "RetentionStart is the oldest instant for which token usage is still\nretained. An explicit period must not start before it. Omitted when the\ndeployment does not purge AI Gateway data.", + "type": "string", + "format": "date-time" + }, + "totals": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverviewTotals" + }, + "users": { + "description": "Users is the requested page, most spend first.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverviewUser" + } + } + } + }, + "codersdk.OrganizationAISpendOverviewGroup": { + "type": "object", + "properties": { + "group_display_name": { + "type": "string" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "group_name": { + "type": "string" + }, + "spend_micros": { + "type": "integer" + } + } + }, + "codersdk.OrganizationAISpendOverviewTotals": { + "type": "object", + "properties": { + "median_spend_micros": { + "description": "MedianSpendMicros is the median spend per user. Zero when no user has\nspend.", + "type": "integer" + }, + "spend_micros": { + "description": "SpendMicros is the spend of every user in the period.", + "type": "integer" + } + } + }, + "codersdk.OrganizationAISpendOverviewUser": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "groups": { + "description": "Groups are the groups the user's spend was attributed to, most spend\nfirst.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationAISpendOverviewGroup" + } + }, + "name": { + "type": "string" + }, + "spend_micros": { + "type": "integer" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, "codersdk.OrganizationAISpendReport": { "type": "object", "properties": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 6ee7f41a83b59..d6a42a7c4bb68 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1641,6 +1641,42 @@ func OrganizationAISpendUser(row database.ListOrganizationAISpendUsersRow) coder } } +func OrganizationAISpendOverviewUsers(rows []database.ListOrganizationAISpendPerUserRow) []codersdk.OrganizationAISpendOverviewUser { + users := make([]codersdk.OrganizationAISpendOverviewUser, 0, len(rows)) + for _, row := range rows { + // Each row is one of a user's groups, and a user's rows are adjacent. + if len(users) == 0 || users[len(users)-1].UserID != row.UserID { + users = append(users, codersdk.OrganizationAISpendOverviewUser{ + UserID: row.UserID, + Username: row.Username, + Name: row.Name, + AvatarURL: row.AvatarURL, + SpendMicros: row.SpendMicros, + Groups: []codersdk.OrganizationAISpendOverviewGroup{}, + }) + } + user := &users[len(users)-1] + user.Groups = append(user.Groups, codersdk.OrganizationAISpendOverviewGroup{ + GroupID: row.GroupID, + GroupName: row.GroupName, + GroupDisplayName: row.GroupDisplayName, + SpendMicros: row.GroupSpendMicros, + }) + } + return users +} + +func OrganizationAISpendDailyBuckets(rows []database.GetOrganizationAISpendPerDayRow) []codersdk.OrganizationAISpendBucket { + return slice.List(rows, func(row database.GetOrganizationAISpendPerDayRow) codersdk.OrganizationAISpendBucket { + start := row.Day.UTC() + return codersdk.OrganizationAISpendBucket{ + Start: start, + End: start.AddDate(0, 0, 1), + SpendMicros: row.SpendMicros, + } + }) +} + func GroupMemberAISpend(row database.GetGroupMembersAISpendRow, queriedGroupID uuid.UUID) codersdk.GroupMemberAISpend { member := codersdk.GroupMemberAISpend{ UserID: row.UserID, diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 1a9890bc9b9b9..283332c77c030 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -715,6 +715,93 @@ func (c *ExperimentalClient) OrganizationAISpendUsers(ctx context.Context, organ return report, ReadBodyAsJSON(res, &report) } +// OrganizationAISpendOverview is the AI spend of an organization over the +// applied period, read from the daily spend aggregate. Every value covers +// the period only through the current day, since later days have no spend +// yet. Count, Totals, and Daily cover every user with spend, not only the +// returned page. +type OrganizationAISpendOverview struct { + AISpendPeriodWindow + // RetentionStart is the oldest instant for which token usage is still + // retained. An explicit period must not start before it. Omitted when the + // deployment does not purge AI Gateway data. + RetentionStart *time.Time `json:"retention_start,omitempty" format:"date-time"` + // Count is the number of users with spend in the period. + Count int64 `json:"count"` + Totals OrganizationAISpendOverviewTotals `json:"totals"` + // Daily has one bucket per UTC day from the period start through the + // current day. Days without spend are zero. + Daily []OrganizationAISpendBucket `json:"daily"` + // Users is the requested page, most spend first. + Users []OrganizationAISpendOverviewUser `json:"users"` +} + +// OrganizationAISpendOverviewTotals aggregates every user with spend in the +// period, not only the returned page. +type OrganizationAISpendOverviewTotals struct { + // SpendMicros is the spend of every user in the period. + SpendMicros int64 `json:"spend_micros"` + // MedianSpendMicros is the median spend per user. Zero when no user has + // spend. + MedianSpendMicros int64 `json:"median_spend_micros"` +} + +// OrganizationAISpendBucket is the spend of an organization over the +// [Start, End) window. +type OrganizationAISpendBucket struct { + Start time.Time `json:"start" format:"date-time"` + End time.Time `json:"end" format:"date-time"` + SpendMicros int64 `json:"spend_micros"` +} + +// OrganizationAISpendOverviewUser is one user's spend within an organization +// overview. +type OrganizationAISpendOverviewUser struct { + UserID uuid.UUID `json:"user_id" format:"uuid"` + Username string `json:"username"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + SpendMicros int64 `json:"spend_micros"` + // Groups are the groups the user's spend was attributed to, most spend + // first. + Groups []OrganizationAISpendOverviewGroup `json:"groups"` +} + +// OrganizationAISpendOverviewGroup is the part of a user's spend attributed +// to one group. +type OrganizationAISpendOverviewGroup struct { + GroupID uuid.UUID `json:"group_id" format:"uuid"` + GroupName string `json:"group_name"` + GroupDisplayName string `json:"group_display_name"` + SpendMicros int64 `json:"spend_micros"` +} + +// OrganizationAISpend returns the AI spend overview of the organization with +// one page of users. Both period bounds are optional and must be at UTC +// midnight, and zero values fall back to the current budget period on the +// server. +func (c *ExperimentalClient) OrganizationAISpend(ctx context.Context, organization uuid.UUID, period AISpendPeriodWindow, page OrganizationAISpendPage) (OrganizationAISpendOverview, error) { + filter := OrganizationAISpendDetailsFilter{ + PeriodStart: period.PeriodStart, + PeriodEnd: period.PeriodEnd, + } + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/experimental/organizations/%s/ai/spend", organization.String()), + nil, + filter.asRequestOption(), + page.asRequestOption(), + ) + if err != nil { + return OrganizationAISpendOverview{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return OrganizationAISpendOverview{}, ReadBodyAsError(res) + } + var overview OrganizationAISpendOverview + return overview, ReadBodyAsJSON(res, &overview) +} + type GroupAIBudget struct { GroupID uuid.UUID `json:"group_id" format:"uuid"` SpendLimitMicros int64 `json:"spend_limit_micros"` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index fad535e6fd015..6f863179944d9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12695,6 +12695,142 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `name` | string | false | | | | `updated_at` | string | true | | | +## codersdk.OrganizationAISpendBucket + +```json +{ + "end": "2019-08-24T14:15:22Z", + "spend_micros": 0, + "start": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|---------|----------|--------------|-------------| +| `end` | string | false | | | +| `spend_micros` | integer | false | | | +| `start` | string | false | | | + +## codersdk.OrganizationAISpendOverview + +```json +{ + "count": 0, + "daily": [ + { + "end": "2019-08-24T14:15:22Z", + "spend_micros": 0, + "start": "2019-08-24T14:15:22Z" + } + ], + "period_end": "2019-08-24T14:15:22Z", + "period_start": "2019-08-24T14:15:22Z", + "retention_start": "2019-08-24T14:15:22Z", + "totals": { + "median_spend_micros": 0, + "spend_micros": 0 + }, + "users": [ + { + "avatar_url": "string", + "groups": [ + { + "group_display_name": "string", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "group_name": "string", + "spend_micros": 0 + } + ], + "name": "string", + "spend_micros": 0, + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------|-----------------------------------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `count` | integer | false | | Count is the number of users with spend in the period. | +| `daily` | array of [codersdk.OrganizationAISpendBucket](#codersdkorganizationaispendbucket) | false | | Daily has one bucket per UTC day from the period start through the current day. Days without spend are zero. | +| `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. | +| `retention_start` | string | false | | Retention start is the oldest instant for which token usage is still retained. An explicit period must not start before it. Omitted when the deployment does not purge AI Gateway data. | +| `totals` | [codersdk.OrganizationAISpendOverviewTotals](#codersdkorganizationaispendoverviewtotals) | false | | | +| `users` | array of [codersdk.OrganizationAISpendOverviewUser](#codersdkorganizationaispendoverviewuser) | false | | Users is the requested page, most spend first. | + +## codersdk.OrganizationAISpendOverviewGroup + +```json +{ + "group_display_name": "string", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "group_name": "string", + "spend_micros": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|---------|----------|--------------|-------------| +| `group_display_name` | string | false | | | +| `group_id` | string | false | | | +| `group_name` | string | false | | | +| `spend_micros` | integer | false | | | + +## codersdk.OrganizationAISpendOverviewTotals + +```json +{ + "median_spend_micros": 0, + "spend_micros": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------|---------|----------|--------------|--------------------------------------------------------------------------------| +| `median_spend_micros` | integer | false | | Median spend micros is the median spend per user. Zero when no user has spend. | +| `spend_micros` | integer | false | | Spend micros is the spend of every user in the period. | + +## codersdk.OrganizationAISpendOverviewUser + +```json +{ + "avatar_url": "string", + "groups": [ + { + "group_display_name": "string", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "group_name": "string", + "spend_micros": 0 + } + ], + "name": "string", + "spend_micros": 0, + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|-------------------------------------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `groups` | array of [codersdk.OrganizationAISpendOverviewGroup](#codersdkorganizationaispendoverviewgroup) | false | | Groups are the groups the user's spend was attributed to, most spend first. | +| `name` | string | false | | | +| `spend_micros` | integer | false | | | +| `user_id` | string | false | | | +| `username` | string | false | | | + ## codersdk.OrganizationAISpendReport ```json diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index df2f3b98b1b84..df1ea0c904b8d 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strconv" "strings" "time" @@ -1395,6 +1396,156 @@ func (api *API) exportOrganizationAISpend(rw http.ResponseWriter, r *http.Reques } } +// parseOrganizationAISpendPage reads the limit and offset of an organization +// AI spend page, recording invalid values on the parser. +func parseOrganizationAISpendPage(parser *httpapi.QueryParamParser, query url.Values) codersdk.OrganizationAISpendPage { + page := codersdk.OrganizationAISpendPage{ + Limit: int(parser.PositiveInt32(query, defaultOrganizationAISpendLimit, "limit")), + Offset: int(parser.PositiveInt32(query, 0, "offset")), + } + if page.Limit < 1 || page.Limit > maxOrganizationAISpendLimit { + parser.Errors = append(parser.Errors, codersdk.ValidationError{ + Field: "limit", + Detail: fmt.Sprintf("Query param \"limit\" must be in range [1, %d].", maxOrganizationAISpendLimit), + }) + } + return page +} + +// isUTCDayStart reports whether t is at midnight UTC. +func isUTCDayStart(t time.Time) bool { + return t.Equal(t.UTC().Truncate(24 * time.Hour)) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +// @Summary Get organization AI spend overview +// @Description Returns the AI spend of the organization from the daily spend aggregate: the number of users with spend, the total and median spend per user, the spend for each UTC day, and one page of users, most spend first, with the groups their spend was attributed to. The count, totals, and daily spend cover every user, not only the returned page. +// @Description The optional period_start and period_end query parameters bound the period. They must be provided together, fall at UTC midnight, and span at most 31 days. When both are omitted, the current UTC monthly period is used. +// @Description An explicit period_start must fall within the configured AI Gateway data retention window, matching the per-user report. The default period is narrowed to that window instead, starting at the next UTC day. The response echoes the applied period, but every value covers it only through the current day, since later days have no spend yet. +// @Description Unknown query parameters are rejected. +// @Description Requires organization-level administrator permissions. +// @ID get-organization-ai-spend-overview +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Param period_start query string false "Inclusive lower bound at UTC midnight (RFC3339)" format(date-time) +// @Param period_end query string false "Exclusive upper bound at UTC midnight (RFC3339)" format(date-time) +// @Param limit query int false "Page size (default 10, maximum 100)" +// @Param offset query int false "Page offset" +// @Success 200 {object} codersdk.OrganizationAISpendOverview +// @Router /api/experimental/organizations/{organization}/ai/spend [get] +// @x-apidocgen {"skip": true} +func (api *API) organizationAISpend(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + org := httpmw.OrganizationParam(r) + logger := api.Logger.With(slog.F("organization_id", org.ID)) + + // dbauthz enforces the same organization-wide read; checking here first + // answers with 403 instead of an internal error. + if !api.Authorize(r, policy.ActionRead, rbac.ResourceGroupMember.InOrg(org.ID)) { + httpapi.Forbidden(rw) + return + } + + query := r.URL.Query() + parser := httpapi.NewQueryParamParser() + page := parseOrganizationAISpendPage(parser, query) + period, ok := api.aiSpendPeriod(ctx, rw, r, parser) + if !ok { + return + } + // The daily spend aggregate holds whole UTC days. + if !isUTCDayStart(period.start) || !isUTCDayStart(period.end) { + if query.Has("period_start") { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters \"period_start\" and \"period_end\" must be at UTC midnight.", + }) + return + } + // Only a default period narrowed to the retention window starts + // mid-day, so start it at the next whole day. + period.start = period.start.UTC().Truncate(24*time.Hour).AddDate(0, 0, 1) + } + logger = logger.With( + slog.F("period_start", period.start), + slog.F("period_end", period.end), + ) + + // Every query reads the period up to the end of today, since later days + // have no spend yet. A period that starts in the future reads no days. + dataEnd := api.Clock.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, 1) + if period.end.Before(dataEnd) { + dataEnd = period.end + } + if dataEnd.Before(period.start) { + dataEnd = period.start + } + + var ( + totals database.GetOrganizationAISpendTotalsRow + users []database.ListOrganizationAISpendPerUserRow + daily []database.GetOrganizationAISpendPerDayRow + ) + err := api.Database.InTx(func(db database.Store) error { + var err error + totals, err = db.GetOrganizationAISpendTotals(ctx, database.GetOrganizationAISpendTotalsParams{ + OrganizationID: org.ID, + PeriodStart: period.start, + PeriodEnd: dataEnd, + }) + if err != nil { + return xerrors.Errorf("get organization AI spend totals: %w", err) + } + users, err = db.ListOrganizationAISpendPerUser(ctx, database.ListOrganizationAISpendPerUserParams{ + OrganizationID: org.ID, + PeriodStart: period.start, + PeriodEnd: dataEnd, + // #nosec G115 - The limit is capped above and the offset is parsed as int32. + LimitOpt: int32(page.Limit), + // #nosec G115 - The limit is capped above and the offset is parsed as int32. + OffsetOpt: int32(page.Offset), + }) + if err != nil { + return xerrors.Errorf("list organization AI spend per user: %w", err) + } + daily, err = db.GetOrganizationAISpendPerDay(ctx, database.GetOrganizationAISpendPerDayParams{ + OrganizationID: org.ID, + PeriodStart: period.start, + PeriodEnd: dataEnd, + }) + if err != nil { + return xerrors.Errorf("get organization AI spend per day: %w", err) + } + return nil + }, &database.TxOptions{ + Isolation: sql.LevelRepeatableRead, // Consistency across queries while spend is being recorded. + ReadOnly: true, + TxIdentifier: "organization_ai_spend_overview", + }) + if err != nil { + logger.Error(ctx, "failed to get organization AI spend overview", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + overview := codersdk.OrganizationAISpendOverview{ + AISpendPeriodWindow: codersdk.AISpendPeriodWindow{PeriodStart: period.start, PeriodEnd: period.end}, + Count: totals.UserCount, + Totals: codersdk.OrganizationAISpendOverviewTotals{ + SpendMicros: totals.SpendMicros, + MedianSpendMicros: totals.MedianSpendMicros, + }, + Daily: db2sdk.OrganizationAISpendDailyBuckets(daily), + Users: db2sdk.OrganizationAISpendOverviewUsers(users), + } + if !period.retentionStart.IsZero() { + overview.RetentionStart = &period.retentionStart + } + httpapi.Write(ctx, rw, http.StatusOK, overview) +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // // @Summary List organization AI spend by user @@ -1433,16 +1584,7 @@ func (api *API) organizationAISpendUsers(rw http.ResponseWriter, r *http.Request query := r.URL.Query() parser := httpapi.NewQueryParamParser() - page := codersdk.OrganizationAISpendPage{ - Limit: int(parser.PositiveInt32(query, defaultOrganizationAISpendLimit, "limit")), - Offset: int(parser.PositiveInt32(query, 0, "offset")), - } - if page.Limit < 1 || page.Limit > maxOrganizationAISpendLimit { - parser.Errors = append(parser.Errors, codersdk.ValidationError{ - Field: "limit", - Detail: fmt.Sprintf("Query param \"limit\" must be in range [1, %d].", maxOrganizationAISpendLimit), - }) - } + page := parseOrganizationAISpendPage(parser, query) // An empty dimension matches every request. providerName := parser.String(query, "", "provider_name") model := parser.String(query, "", "model") diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 44fb4e14a7c79..517f32aa2abc7 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -5931,6 +5931,236 @@ func TestOrganizationAISpendUsers(t *testing.T) { }) } +func TestOrganizationAISpend(t *testing.T) { + t.Parallel() + + // Use fixed dates to keep every subtest deterministic. + now := time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC) + monthStart := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) + monthEnd := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC) + // setupAICostControlTest keeps the default 60d retention. + defaultRetentionStart := now.Add(-60 * 24 * time.Hour) + + // days returns one bucket per UTC day in [start, end), with the given + // spend by day. + days := func(start, end time.Time, spend map[time.Time]int64) []codersdk.OrganizationAISpendBucket { + buckets := []codersdk.OrganizationAISpendBucket{} + for day := start; day.Before(end); day = day.AddDate(0, 0, 1) { + buckets = append(buckets, codersdk.OrganizationAISpendBucket{Start: day, End: day.AddDate(0, 0, 1), SpendMicros: spend[day]}) + } + return buckets + } + + t.Run("Validation", func(t *testing.T) { + t.Parallel() + clock := quartz.NewMock(t) + clock.Set(now) + adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{ + GroupName: "spend-overview-validation-group", + Clock: clock, + }) + + cases := []struct { + name string + params map[string]string + wantStatus int + wantMessage string + }{ + {name: "LimitZero", params: map[string]string{"limit": "0"}, wantStatus: http.StatusBadRequest, wantMessage: "have invalid values"}, + {name: "LimitAboveMax", params: map[string]string{"limit": "101"}, wantStatus: http.StatusBadRequest, wantMessage: "have invalid values"}, + {name: "LimitMax", params: map[string]string{"limit": "100"}, wantStatus: http.StatusOK}, + {name: "NegativeOffset", params: map[string]string{"offset": "-1"}, wantStatus: http.StatusBadRequest, wantMessage: "have invalid values"}, + {name: "UnknownParameter", params: map[string]string{"provider_name": "openai"}, wantStatus: http.StatusBadRequest, wantMessage: "have invalid values"}, + {name: "OnlyStart", params: map[string]string{"period_start": monthStart.Format(time.RFC3339)}, wantStatus: http.StatusBadRequest, wantMessage: "provided together"}, + { + name: "StartNotAtMidnight", + params: map[string]string{"period_start": monthStart.Add(time.Hour).Format(time.RFC3339), "period_end": monthEnd.Format(time.RFC3339)}, + wantStatus: http.StatusBadRequest, + wantMessage: "UTC midnight", + }, + { + name: "EndNotAtUTCMidnight", + params: map[string]string{"period_start": monthStart.Format(time.RFC3339), "period_end": "2026-03-10T00:00:00+02:00"}, + wantStatus: http.StatusBadRequest, + wantMessage: "UTC midnight", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + res := requestAISpend(ctx, t, adminClient, fmt.Sprintf("/api/experimental/organizations/%s/ai/spend", group.OrganizationID), tc.params) + defer res.Body.Close() + require.Equal(t, tc.wantStatus, res.StatusCode) + if tc.wantStatus != http.StatusOK { + var sdkErr *codersdk.Error + require.ErrorAs(t, codersdk.ReadBodyAsError(res), &sdkErr) + require.Contains(t, sdkErr.Message, tc.wantMessage) + } + }) + } + }) + + t.Run("Overview", func(t *testing.T) { + t.Parallel() + clock := quartz.NewMock(t) + clock.Set(now) + db, ps := dbtestutil.NewDB(t) + adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{ + GroupName: "spend-overview-group", + Clock: clock, + Database: db, + Pubsub: ps, + }) + memberClient, otherUser := coderdtest.CreateAnotherUser(t, adminClient, group.OrganizationID) + + march2 := monthStart.AddDate(0, 0, 1) + march10 := monthStart.AddDate(0, 0, 9) + march20 := monthStart.AddDate(0, 0, 19) + ctx := testutil.Context(t, testutil.WaitLong) + for _, seed := range []struct { + user codersdk.User + day time.Time + spend int64 + }{ + {user: targetUser, day: march2, spend: 1000}, + {user: targetUser, day: march10, spend: 500}, + {user: otherUser, day: march10, spend: 3000}, + // Excluded: dated after today, so no value covers it. + {user: otherUser, day: march20, spend: 99_999}, + } { + _, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{ + UserID: seed.user.ID, EffectiveGroupID: group.ID, Day: seed.day, CostMicros: seed.spend, + }) + require.NoError(t, err) + } + + user := func(u codersdk.User, spend int64) codersdk.OrganizationAISpendOverviewUser { + return codersdk.OrganizationAISpendOverviewUser{ + UserID: u.ID, Username: u.Username, Name: u.Name, AvatarURL: u.AvatarURL, SpendMicros: spend, + Groups: []codersdk.OrganizationAISpendOverviewGroup{{ + GroupID: group.ID, GroupName: group.Name, GroupDisplayName: group.DisplayName, SpendMicros: spend, + }}, + } + } + other := user(otherUser, 3000) + target := user(targetUser, 1500) + + t.Run("Default", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + overview, err := codersdk.NewExperimentalClient(adminClient).OrganizationAISpend(ctx, group.OrganizationID, codersdk.AISpendPeriodWindow{}, codersdk.OrganizationAISpendPage{}) + require.NoError(t, err) + require.Equal(t, codersdk.OrganizationAISpendOverview{ + AISpendPeriodWindow: codersdk.AISpendPeriodWindow{PeriodStart: monthStart, PeriodEnd: monthEnd}, + RetentionStart: &defaultRetentionStart, + Count: 2, + Totals: codersdk.OrganizationAISpendOverviewTotals{SpendMicros: 4500, MedianSpendMicros: 2250}, + // Daily spend stops at today, March 15. + Daily: days(monthStart, monthStart.AddDate(0, 0, 15), map[time.Time]int64{march2: 1000, march10: 3500}), + Users: []codersdk.OrganizationAISpendOverviewUser{other, target}, + }, overview) + }) + + t.Run("Pages", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + // Count, totals, and daily spend describe the whole period on every + // page. + page, err := codersdk.NewExperimentalClient(adminClient).OrganizationAISpend(ctx, group.OrganizationID, codersdk.AISpendPeriodWindow{}, codersdk.OrganizationAISpendPage{Limit: 1, Offset: 1}) + require.NoError(t, err) + require.Equal(t, []codersdk.OrganizationAISpendOverviewUser{target}, page.Users) + require.EqualValues(t, 2, page.Count) + require.EqualValues(t, 4500, page.Totals.SpendMicros) + require.Len(t, page.Daily, 15) + + empty, err := codersdk.NewExperimentalClient(adminClient).OrganizationAISpend(ctx, group.OrganizationID, codersdk.AISpendPeriodWindow{}, codersdk.OrganizationAISpendPage{Offset: 2}) + require.NoError(t, err) + require.Empty(t, empty.Users) + require.NotNil(t, empty.Users) + require.EqualValues(t, 2, empty.Count) + }) + + t.Run("ExplicitPeriod", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + window := codersdk.AISpendPeriodWindow{PeriodStart: march2, PeriodEnd: march2.AddDate(0, 0, 2)} + overview, err := codersdk.NewExperimentalClient(adminClient).OrganizationAISpend(ctx, group.OrganizationID, window, codersdk.OrganizationAISpendPage{}) + require.NoError(t, err) + require.Equal(t, codersdk.OrganizationAISpendOverview{ + AISpendPeriodWindow: window, + RetentionStart: &defaultRetentionStart, + Count: 1, + Totals: codersdk.OrganizationAISpendOverviewTotals{SpendMicros: 1000, MedianSpendMicros: 1000}, + Daily: days(window.PeriodStart, window.PeriodEnd, map[time.Time]int64{march2: 1000}), + Users: []codersdk.OrganizationAISpendOverviewUser{user(targetUser, 1000)}, + }, overview) + }) + + // A period after today echoes the applied period without any spend. + t.Run("FuturePeriod", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + window := codersdk.AISpendPeriodWindow{PeriodStart: march20, PeriodEnd: march20.AddDate(0, 0, 2)} + overview, err := codersdk.NewExperimentalClient(adminClient).OrganizationAISpend(ctx, group.OrganizationID, window, codersdk.OrganizationAISpendPage{}) + require.NoError(t, err) + require.Equal(t, codersdk.OrganizationAISpendOverview{ + AISpendPeriodWindow: window, + RetentionStart: &defaultRetentionStart, + Daily: []codersdk.OrganizationAISpendBucket{}, + Users: []codersdk.OrganizationAISpendOverviewUser{}, + }, overview) + }) + + // The overview covers the whole organization, so a regular member is + // rejected. + t.Run("Member", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + _, err := codersdk.NewExperimentalClient(memberClient).OrganizationAISpend(ctx, group.OrganizationID, codersdk.AISpendPeriodWindow{}, codersdk.OrganizationAISpendPage{}) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + }) + + t.Run("RetentionNarrowsDefaultPeriod", func(t *testing.T) { + t.Parallel() + clock := quartz.NewMock(t) + clock.Set(now) + retention := 7 * 24 * time.Hour + adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{ + GroupName: "spend-overview-retention-group", + Clock: clock, + Retention: &retention, + }) + ctx := testutil.Context(t, testutil.WaitLong) + retentionStart := now.Add(-retention) + // Retention starts at noon on March 8, so the period starts on the + // next whole day. + periodStart := time.Date(2026, time.March, 9, 0, 0, 0, 0, time.UTC) + + overview, err := codersdk.NewExperimentalClient(adminClient).OrganizationAISpend(ctx, group.OrganizationID, codersdk.AISpendPeriodWindow{}, codersdk.OrganizationAISpendPage{}) + require.NoError(t, err) + require.Equal(t, codersdk.OrganizationAISpendOverview{ + AISpendPeriodWindow: codersdk.AISpendPeriodWindow{PeriodStart: periodStart, PeriodEnd: monthEnd}, + RetentionStart: &retentionStart, + Daily: days(periodStart, monthStart.AddDate(0, 0, 15), nil), + Users: []codersdk.OrganizationAISpendOverviewUser{}, + }, overview) + + // An explicit period before retention is rejected like the report. + _, err = codersdk.NewExperimentalClient(adminClient).OrganizationAISpend(ctx, group.OrganizationID, codersdk.AISpendPeriodWindow{ + PeriodStart: periodStart.AddDate(0, 0, -1), PeriodEnd: periodStart, + }, codersdk.OrganizationAISpendPage{}) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "retention window") + }) +} + func TestOrganizationAISpendUsersRoleAccess(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index c4ca3b5e7f341..feb0ce5c172a4 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -364,6 +364,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { httpmw.ExtractOrganizationParam(api.Database), api.RequireFeatureMW(codersdk.FeatureAIBridge), ) + r.Get("/", api.organizationAISpend) r.Get("/users", api.organizationAISpendUsers) }) }) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 25ebd0280700e..1382533d51c1d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7953,6 +7953,17 @@ export interface Organization extends MinimalOrganization { readonly default_org_member_roles: readonly string[]; } +// From codersdk/aibridge.go +/** + * OrganizationAISpendBucket is the spend of an organization over the + * [Start, End) window. + */ +export interface OrganizationAISpendBucket { + readonly start: string; + readonly end: string; + readonly spend_micros: number; +} + // From codersdk/aibridge.go /** * OrganizationAISpendDetailsFilter narrows organization AI spend. @@ -7992,6 +8003,84 @@ export interface OrganizationAISpendFilter { readonly client?: string; } +// From codersdk/aibridge.go +/** + * OrganizationAISpendOverview is the AI spend of an organization over the + * applied period, read from the daily spend aggregate. Every value covers + * the period only through the current day, since later days have no spend + * yet. Count, Totals, and Daily cover every user with spend, not only the + * returned page. + */ +export interface OrganizationAISpendOverview extends AISpendPeriodWindow { + /** + * RetentionStart is the oldest instant for which token usage is still + * retained. An explicit period must not start before it. Omitted when the + * deployment does not purge AI Gateway data. + */ + readonly retention_start?: string; + /** + * Count is the number of users with spend in the period. + */ + readonly count: number; + readonly totals: OrganizationAISpendOverviewTotals; + /** + * Daily has one bucket per UTC day from the period start through the + * current day. Days without spend are zero. + */ + readonly daily: readonly OrganizationAISpendBucket[]; + /** + * Users is the requested page, most spend first. + */ + readonly users: readonly OrganizationAISpendOverviewUser[]; +} + +// From codersdk/aibridge.go +/** + * OrganizationAISpendOverviewGroup is the part of a user's spend attributed + * to one group. + */ +export interface OrganizationAISpendOverviewGroup { + readonly group_id: string; + readonly group_name: string; + readonly group_display_name: string; + readonly spend_micros: number; +} + +// From codersdk/aibridge.go +/** + * OrganizationAISpendOverviewTotals aggregates every user with spend in the + * period, not only the returned page. + */ +export interface OrganizationAISpendOverviewTotals { + /** + * SpendMicros is the spend of every user in the period. + */ + readonly spend_micros: number; + /** + * MedianSpendMicros is the median spend per user. Zero when no user has + * spend. + */ + readonly median_spend_micros: number; +} + +// From codersdk/aibridge.go +/** + * OrganizationAISpendOverviewUser is one user's spend within an organization + * overview. + */ +export interface OrganizationAISpendOverviewUser { + readonly user_id: string; + readonly username: string; + readonly name: string; + readonly avatar_url: string; + readonly spend_micros: number; + /** + * Groups are the groups the user's spend was attributed to, most spend + * first. + */ + readonly groups: readonly OrganizationAISpendOverviewGroup[]; +} + // From codersdk/aibridge.go /** * OrganizationAISpendPage selects one page of the per-user report, which