mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
## Problem
AI budget admin notifications tell an admin that a user crossed a budget
threshold, but give them nothing to act on: no link to the user's
budget, and no pointer to the docs.
## Changes
- Add a **Manage user AI budget** action to both admin templates,
linking to the effective group's members page filtered to the affected
user, where the per-user override is set.
- Link to the AI budget documentation from both notification bodies.
- Say when the block lifts in the limit-reached notification ("blocked
until `{{period_end}}`").
- Add an `organization_name` label, needed to build the members page
URL.
- Grant the AI Gateway daemon subject read access to organizations,
which the label lookup requires.
- Add an authorization test for the notification path, which also covers
the existing group read grant.
<details>
<summary>Screenshots</summary>
**Admin Notifications in Coder UI**
<img width="456" height="695" alt="admin_notifications"
src="https://github.com/user-attachments/assets/a579f91c-55cc-497c-87f4-235f15493725"
/>
**Admin Notification in Email**
<img width="660" height="399" alt="admin_notifications_email"
src="https://github.com/user-attachments/assets/39b876fb-f512-41e5-997f-a5269cd26ddc"
/>
</details>
Related to internal Slack threads:
* https://codercom.slack.com/archives/C096PFVBZKN/p1786051630688569
* https://codercom.slack.com/archives/C04M9CNGHGF/p1786453662306149
> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by
@ssncferreira
214 lines
8.0 KiB
Go
214 lines
8.0 KiB
Go
package aibridgedserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/aibridge/budget"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/notifications"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// warningThresholdPercent triggers a warning notification; limitThresholdPercent
|
|
// triggers the limit-reached notification (after which requests are blocked).
|
|
const (
|
|
warningThresholdPercent = 85
|
|
limitThresholdPercent = 100
|
|
)
|
|
|
|
// budgetNotificationsCreatedBy records what enqueued AI budget notifications.
|
|
const budgetNotificationsCreatedBy = "aigateway"
|
|
|
|
// budgetThreshold pairs a percentage of the spend limit with the notifications
|
|
// sent when a user's spend crosses it.
|
|
type budgetThreshold struct {
|
|
percent int
|
|
userNotificationTemplate uuid.UUID
|
|
adminNotificationTemplate uuid.UUID
|
|
}
|
|
|
|
// budgetThresholds are the thresholds evaluated on every priced interception,
|
|
// ordered ascending. A single interception can cross more than one.
|
|
var budgetThresholds = []budgetThreshold{
|
|
{
|
|
percent: warningThresholdPercent,
|
|
userNotificationTemplate: notifications.TemplateAIBudgetWarningUser,
|
|
adminNotificationTemplate: notifications.TemplateAIBudgetWarningAdmin,
|
|
},
|
|
{
|
|
percent: limitThresholdPercent,
|
|
userNotificationTemplate: notifications.TemplateAIBudgetLimitReachedUser,
|
|
adminNotificationTemplate: notifications.TemplateAIBudgetLimitReachedAdmin,
|
|
},
|
|
}
|
|
|
|
// budgetThresholdCrossing describes a user crossing a budget threshold on a
|
|
// single interception. It carries only stable values so that if the same
|
|
// crossing is ever enqueued more than once, the payloads match and the
|
|
// duplicate is dropped.
|
|
type budgetThresholdCrossing struct {
|
|
userID uuid.UUID
|
|
effectiveGroupID uuid.UUID
|
|
spendLimitMicros int64
|
|
thresholdPercent int
|
|
userNotificationTemplate uuid.UUID
|
|
adminNotificationTemplate uuid.UUID
|
|
limitSource codersdk.AIBudgetLimitSource
|
|
// periodStart and periodEnd bound the budget period [start, end) the
|
|
// crossing occurred in.
|
|
periodStart time.Time
|
|
periodEnd time.Time
|
|
}
|
|
|
|
// detectBudgetThresholdCrossings checks whether this interception's cost pushed
|
|
// the user's period spend across any budget thresholds, returning each one
|
|
// crossed. A single interception can cross several at once (e.g. straight past
|
|
// both the warning and limit thresholds).
|
|
//
|
|
// The period is derived from the interception's recorded time (the same
|
|
// timestamp the spend row is bucketed by) rather than the current wall clock,
|
|
// so an interception recorded near a period boundary but processed after it is
|
|
// evaluated against the period it belongs to.
|
|
func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database.Store, intc database.AIBridgeInterception, cost tokenUsageCost, createdAt time.Time) ([]budgetThresholdCrossing, error) {
|
|
if !cost.effectiveGroupID.Valid || !cost.spendLimitMicros.Valid || cost.spendLimitMicros.Int64 <= 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
period, err := budget.CurrentPeriod(createdAt, s.budgetPeriod)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("compute AI budget period: %w", err)
|
|
}
|
|
|
|
spend, err := tx.GetUserAISpendSince(ctx, database.GetUserAISpendSinceParams{
|
|
UserID: intc.InitiatorID,
|
|
EffectiveGroupID: cost.effectiveGroupID.UUID,
|
|
PeriodStart: period.Start,
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get user AI spend for user %q in group %q: %w", intc.InitiatorID, cost.effectiveGroupID.UUID, err)
|
|
}
|
|
|
|
limit := cost.spendLimitMicros.Int64
|
|
newSpend := spend.SpendMicros
|
|
// Pre-interception total is the current total minus this interception's cost.
|
|
oldSpend := newSpend - cost.costMicros.Int64
|
|
|
|
var crossings []budgetThresholdCrossing
|
|
for _, t := range budgetThresholds {
|
|
at := limit * int64(t.percent) / 100
|
|
if oldSpend < at && newSpend >= at {
|
|
crossings = append(crossings, budgetThresholdCrossing{
|
|
userID: intc.InitiatorID,
|
|
effectiveGroupID: cost.effectiveGroupID.UUID,
|
|
spendLimitMicros: limit,
|
|
thresholdPercent: t.percent,
|
|
userNotificationTemplate: t.userNotificationTemplate,
|
|
adminNotificationTemplate: t.adminNotificationTemplate,
|
|
limitSource: cost.limitSource,
|
|
periodStart: period.Start,
|
|
periodEnd: period.End,
|
|
})
|
|
}
|
|
}
|
|
return crossings, nil
|
|
}
|
|
|
|
// notifyBudgetThresholdCrossings enqueues user and admin notifications for the
|
|
// thresholds crossed by a single interception. All crossings share the same
|
|
// user and effective group, so the group, username, and admin recipients are
|
|
// resolved once.
|
|
func (s *Server) notifyBudgetThresholdCrossings(ctx context.Context, crossings []budgetThresholdCrossing) error {
|
|
if len(crossings) == 0 {
|
|
return nil
|
|
}
|
|
|
|
userID := crossings[0].userID
|
|
effectiveGroupID := crossings[0].effectiveGroupID
|
|
|
|
group, err := s.store.GetGroupByID(ctx, effectiveGroupID)
|
|
if err != nil {
|
|
return xerrors.Errorf("look up group %q: %w", effectiveGroupID, err)
|
|
}
|
|
org, err := s.store.GetOrganizationByID(ctx, group.OrganizationID)
|
|
if err != nil {
|
|
return xerrors.Errorf("look up organization %q: %w", group.OrganizationID, err)
|
|
}
|
|
user, err := s.store.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
return xerrors.Errorf("look up user %q: %w", userID, err)
|
|
}
|
|
admins, err := s.budgetNotificationAdmins(ctx, userID)
|
|
if err != nil {
|
|
return xerrors.Errorf("look up budget notification admins: %w", err)
|
|
}
|
|
|
|
//nolint:gocritic // Enqueuing notifications requires the notifier actor.
|
|
notifCtx := dbauthz.AsNotifier(ctx)
|
|
|
|
var errs []error
|
|
for _, c := range crossings {
|
|
labels := map[string]string{
|
|
"threshold": strconv.Itoa(c.thresholdPercent),
|
|
"limit": formatSpendLimit(c.spendLimitMicros),
|
|
"period": s.budgetPeriod.Adjective(),
|
|
"limit_source": string(c.limitSource),
|
|
"username": user.Username,
|
|
"effective_group_name": group.Name,
|
|
"organization_name": org.Name,
|
|
// Both bounds carry the year so a period straddling a year boundary
|
|
// (e.g. December 1, 2026 - January 1, 2027) is unambiguous.
|
|
"period_start": c.periodStart.UTC().Format("January 2, 2006"),
|
|
"period_end": c.periodEnd.UTC().Format("January 2, 2006"),
|
|
}
|
|
|
|
// Notify the user who crossed the threshold.
|
|
if _, err := s.notifEnqueuer.EnqueueWithData(notifCtx, userID, c.userNotificationTemplate,
|
|
labels, nil, budgetNotificationsCreatedBy, effectiveGroupID); err != nil {
|
|
errs = append(errs, xerrors.Errorf("enqueue user notification (threshold %d%%): %w", c.thresholdPercent, err))
|
|
}
|
|
|
|
// Notify admins, naming the affected user.
|
|
for _, admin := range admins {
|
|
if _, err := s.notifEnqueuer.EnqueueWithData(notifCtx, admin.ID, c.adminNotificationTemplate,
|
|
labels, nil, budgetNotificationsCreatedBy, userID, effectiveGroupID); err != nil {
|
|
errs = append(errs, xerrors.Errorf("enqueue admin notification (threshold %d%%, admin %q): %w", c.thresholdPercent, admin.ID, err))
|
|
}
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// budgetNotificationAdmins returns the users who should receive admin budget
|
|
// notifications: deployment owners and user admins, excluding the affected
|
|
// user, who receives the user-facing notification instead.
|
|
func (s *Server) budgetNotificationAdmins(ctx context.Context, excludeUserID uuid.UUID) ([]database.GetUsersRow, error) {
|
|
admins, err := s.store.GetUsers(ctx, database.GetUsersParams{
|
|
RbacRole: []string{codersdk.RoleOwner, codersdk.RoleUserAdmin},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
recipients := make([]database.GetUsersRow, 0, len(admins))
|
|
for _, admin := range admins {
|
|
if admin.ID == excludeUserID {
|
|
continue
|
|
}
|
|
recipients = append(recipients, admin)
|
|
}
|
|
return recipients, nil
|
|
}
|
|
|
|
// formatSpendLimit renders a spend limit as a USD string.
|
|
func formatSpendLimit(micros int64) string {
|
|
return fmt.Sprintf("$%.2f", float64(micros)/1_000_000)
|
|
}
|