mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Implements: https://linear.app/codercom/issue/AIGOV-289/notify-users-and-admins-on-budget-warning-and-limit-reached Notify admins when a user crosses an AI budget threshold, complementing the user-facing notifications from https://github.com/coder/coder/pull/27346 When a priced interception pushes a user's period spend across the warning (85%) or limit (100%) threshold, the Owners and User Admins now receive an admin notification naming the affected user, alongside the user's own notification. The affected user is excluded from the admin recipients since they already get the user-facing copy. Delivery is best-effort: a failure to enqueue is logged and never blocks recording the interception. The admin templates always show the effective group the spend is attributed to, and note when the limit comes from a per-user override rather than the group budget. Depends on https://github.com/coder/coder/pull/27346 ## Screenshots: <img width="1101" height="440" alt="image" src="https://github.com/user-attachments/assets/eb731088-05c8-47bd-9d06-fc9d07f63a08" /> <img width="468" height="391" alt="image" src="https://github.com/user-attachments/assets/b89b76a6-3fa8-4735-99a2-43e119a7a7e3" />
209 lines
7.8 KiB
Go
209 lines
7.8 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)
|
|
}
|
|
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,
|
|
// 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)
|
|
}
|