Files
coder/coderd/aibridgedserver/notifications.go
T
Susana Ferreira 363d7fc4d9 feat: add CTAs to AI budget admin notifications (#28138)
## 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
2026-08-20 14:38:33 +01:00

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)
}