mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add per-group AI budget table and endpoints (#25203)
Closes https://linear.app/codercom/issue/AIGOV-284/add-group-budgets-table-and-crud-api ## Summary Adds the `group_ai_budgets` table and the following endpoints: - `GET /api/v2/groups/{group}/ai/budget` - `PUT /api/v2/groups/{group}/ai/budget` - `DELETE /api/v2/groups/{group}/ai/budget` Each group may have at most one budget row. If no row exists, no budget is enforced. ### Feature gate Added `RequireFeatureMW(FeatureAIBridge)` on the `/ai/budget` sub-route. ## RBAC Authorization reuses `rbac.ResourceGroup` with the existing `.InOrganization(...).WithID(...)` scoping model. The `dbauthz` wrappers load the parent `groups` row and authorize against it. No new resource type is introduced. As a result, anyone with `group:update` permissions (Owner, OrgAdmin, or UserAdmin within the organization) can manage AI budgets for that group. ## Read access for group members `database.Group.RBACObject()` grants `policy.ActionRead` to all members of the group through the group ACL: ```go func (g Group) RBACObject() rbac.Object { return rbac.ResourceGroup.WithID(g.ID). InOrg(g.OrganizationID). // Group members can read the group. WithGroupACL(map[string][]policy.Action{ g.ID.String(): { policy.ActionRead, }, }) } ``` Because the `GET` endpoint authorizes against the same loaded `Group` object, any group member can call: ```text GET /api/v2/groups/{group}/ai/budget ``` `PUT` and `DELETE` remain admin-only. The group ACL grants only `ActionRead`, so write operations continue to require role-based `group:update` permissions. ## Alternative considered A dedicated `rbac.ResourceGroupAiBudget` resource would allow budget management to be separated from general group administration. We decided not to add that complexity for now.
This commit is contained in:
@@ -2280,6 +2280,22 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteGroupAIBudget = `-- name: DeleteGroupAIBudget :one
|
||||
DELETE FROM group_ai_budgets WHERE group_id = $1 RETURNING group_id, spend_limit_micros, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteGroupAIBudget(ctx context.Context, groupID uuid.UUID) (GroupAiBudget, error) {
|
||||
row := q.db.QueryRowContext(ctx, deleteGroupAIBudget, groupID)
|
||||
var i GroupAiBudget
|
||||
err := row.Scan(
|
||||
&i.GroupID,
|
||||
&i.SpendLimitMicros,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAIModelPriceByProviderModel = `-- name: GetAIModelPriceByProviderModel :one
|
||||
SELECT provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at
|
||||
FROM ai_model_prices
|
||||
@@ -2307,6 +2323,24 @@ func (q *sqlQuerier) GetAIModelPriceByProviderModel(ctx context.Context, arg Get
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGroupAIBudget = `-- name: GetGroupAIBudget :one
|
||||
SELECT group_id, spend_limit_micros, created_at, updated_at
|
||||
FROM group_ai_budgets
|
||||
WHERE group_id = $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (GroupAiBudget, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGroupAIBudget, groupID)
|
||||
var i GroupAiBudget
|
||||
err := row.Scan(
|
||||
&i.GroupID,
|
||||
&i.SpendLimitMicros,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertAIModelPrices = `-- name: UpsertAIModelPrices :exec
|
||||
INSERT INTO ai_model_prices (
|
||||
provider, model, input_price, output_price, cache_read_price, cache_write_price
|
||||
@@ -2335,6 +2369,32 @@ func (q *sqlQuerier) UpsertAIModelPrices(ctx context.Context, seed json.RawMessa
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertGroupAIBudget = `-- name: UpsertGroupAIBudget :one
|
||||
INSERT INTO group_ai_budgets (group_id, spend_limit_micros)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (group_id) DO UPDATE SET
|
||||
spend_limit_micros = EXCLUDED.spend_limit_micros,
|
||||
updated_at = NOW()
|
||||
RETURNING group_id, spend_limit_micros, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertGroupAIBudgetParams struct {
|
||||
GroupID uuid.UUID `db:"group_id" json:"group_id"`
|
||||
SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpsertGroupAIBudget(ctx context.Context, arg UpsertGroupAIBudgetParams) (GroupAiBudget, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertGroupAIBudget, arg.GroupID, arg.SpendLimitMicros)
|
||||
var i GroupAiBudget
|
||||
err := row.Scan(
|
||||
&i.GroupID,
|
||||
&i.SpendLimitMicros,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one
|
||||
SELECT
|
||||
COUNT(*)
|
||||
|
||||
Reference in New Issue
Block a user