From 9604e3a660549ab9555d39b6951ee00bdeb287c6 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 13:40:01 +0000 Subject: [PATCH 1/3] feat: notify admins when a user crosses an AI budget threshold --- coderd/aibridgedserver/aibridgedserver.go | 13 +- .../aibridgedserver/aibridgedserver_test.go | 162 +++++++++++++++++- coderd/aibridgedserver/cost.go | 5 +- coderd/aibridgedserver/notifications.go | 145 +++++++++++----- ...553_ai_budget_admin_notifications.down.sql | 5 + ...00553_ai_budget_admin_notifications.up.sql | 61 +++++++ coderd/notifications/events.go | 6 +- coderd/notifications/notifications_test.go | 39 +++++ ...plateAIBudgetLimitReachedAdmin.html.golden | 82 +++++++++ .../TemplateAIBudgetWarningAdmin.html.golden | 77 +++++++++ ...plateAIBudgetLimitReachedAdmin.json.golden | 29 ++++ .../TemplateAIBudgetWarningAdmin.json.golden | 30 ++++ 12 files changed, 600 insertions(+), 54 deletions(-) create mode 100644 coderd/database/migrations/000553_ai_budget_admin_notifications.down.sql create mode 100644 coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningAdmin.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 62f8e1132e19d..3b217e9a15e43 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -84,6 +84,7 @@ type store interface { GetUserEveryoneFallbackGroup(ctx context.Context, userID uuid.UUID) (uuid.UUID, error) GetUserAISpendSince(ctx context.Context, arg database.GetUserAISpendSinceParams) (database.GetUserAISpendSinceRow, error) GetGroupByID(ctx context.Context, id uuid.UUID) (database.Group, error) + GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) // MCPConfigurator-related queries. GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.ExternalAuthLink, error) @@ -433,14 +434,10 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB return err } - for _, crossing := range crossings { - if err := s.notifyBudgetThresholdCrossing(ctx, crossing); err != nil { - s.logger.Error(ctx, "failed to send AI budget notification", - slog.F("user_id", crossing.userID), - slog.F("group_id", crossing.effectiveGroupID), - slog.F("threshold_percent", crossing.thresholdPercent), - slog.Error(err)) - } + if err := s.notifyBudgetThresholdCrossings(ctx, crossings); err != nil { + s.logger.Warn(ctx, "failed to send AI budget notifications", + slog.F("initiator_id", intc.InitiatorID), + slog.Error(err)) } return nil } diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 1e54445b7ab83..c3940583f9d37 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2420,10 +2420,16 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) { Return(database.AIUserDailySpend{}, nil) db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). Return(database.GetUserAISpendSinceRow{SpendMicros: tc.newSpend}, nil) - // The group is looked up once per crossing that notifies. - db.EXPECT().GetGroupByID(gomock.Any(), groupID). - Return(database.Group{ID: groupID, Name: "Engineering"}, nil). - Times(len(tc.wantTemplates)) + // The group and user are resolved once per interception that + // notifies, regardless of how many thresholds it crosses. + if len(tc.wantTemplates) > 0 { + db.EXPECT().GetGroupByID(gomock.Any(), groupID). + Return(database.Group{ID: groupID, Name: "Engineering"}, nil) + db.EXPECT().GetUserByID(gomock.Any(), intc.InitiatorID). + Return(database.User{ID: intc.InitiatorID, Username: "bob"}, nil) + // No admins configured, so only the user is notified. + db.EXPECT().GetUsers(gomock.Any(), gomock.Any()).Return(nil, nil) + } ctx := testutil.Context(t, testutil.WaitLong) srv, err := aibridgedserver.NewServer(ctx, aibridgedserver.Options{ @@ -2514,6 +2520,10 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { }) db.EXPECT().GetGroupByID(gomock.Any(), groupID). Return(database.Group{ID: groupID, Name: "Engineering"}, nil) + db.EXPECT().GetUserByID(gomock.Any(), intc.InitiatorID). + Return(database.User{ID: intc.InitiatorID, Username: "bob"}, nil) + // No admins configured, so only the user is notified. + db.EXPECT().GetUsers(gomock.Any(), gomock.Any()).Return(nil, nil) clock := quartz.NewMock(t) clock.Set(processedAt) @@ -2695,6 +2705,150 @@ func TestRecordTokenUsageBudgetNotificationZeroLimit(t *testing.T) { require.Empty(t, enq.Sent(), "a zero spend limit must not produce a notification") } +// TestRecordTokenUsageBudgetAdminNotification verifies that crossing a budget +// threshold also notifies deployment owners and user admins, and that the +// affected user is not double-notified as an admin. +func TestRecordTokenUsageBudgetAdminNotification(t *testing.T) { + t.Parallel() + + const ( + dollar int64 = 1_000_000 // micros per USD dollar + spendLimit int64 = 100 * dollar // $100 limit (100% threshold) + warnAt int64 = 85 * dollar // $85 (85% of the limit) + inputPrice int64 = dollar // $1 per million tokens + tokensPerDollar int64 = 1_000_000 // 1,000,000 tokens = $1 at the price above + ) + now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) + + cases := []struct { + name string + useOverride bool + inputTokens int64 + newSpend int64 + wantThreshold string + wantLimitSource string + wantUserTemplate uuid.UUID + wantAdminTemplate uuid.UUID + }{ + { + // A $1 interception takes spend to warnAt ($85): pre ($84) < warnAt <= post. + name: "warning", + inputTokens: tokensPerDollar, + newSpend: warnAt, + wantThreshold: "85", + wantLimitSource: string(codersdk.AIBudgetLimitSourceGroup), + wantUserTemplate: notifications.TemplateAIBudgetWarningUser, + wantAdminTemplate: notifications.TemplateAIBudgetWarningAdmin, + }, + { + // A $5 interception takes spend from $95 to the $100 limit, so the limit threshold is crossed. + name: "limit reached", + inputTokens: 5 * tokensPerDollar, + newSpend: spendLimit, + wantThreshold: "100", + wantLimitSource: string(codersdk.AIBudgetLimitSourceGroup), + wantUserTemplate: notifications.TemplateAIBudgetLimitReachedUser, + wantAdminTemplate: notifications.TemplateAIBudgetLimitReachedAdmin, + }, + { + // A per-user override supplies the limit, so limit_source is user_override. + name: "warning, per-user override", + useOverride: true, + inputTokens: tokensPerDollar, + newSpend: warnAt, + wantThreshold: "85", + wantLimitSource: string(codersdk.AIBudgetLimitSourceUserOverride), + wantUserTemplate: notifications.TemplateAIBudgetWarningUser, + wantAdminTemplate: notifications.TemplateAIBudgetWarningAdmin, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + price := &database.AIModelPrice{ + InputPrice: sql.NullInt64{Int64: inputPrice, Valid: true}, + } + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + enq := ¬ificationstest.FakeEnqueuer{} + + intc := newTestInterception(uuid.New()) + groupID := uuid.New() + + admin := database.GetUsersRow{ID: uuid.New(), Username: "admin1"} + // The affected user is also an admin; they must not receive the admin copy. + selfAdmin := database.GetUsersRow{ID: intc.InitiatorID, Username: "bob"} + + // Whether the limit comes from a group budget or a per-user override, the + // spend is attributed to the same effective group, so the group lookup and + // notifications are identical apart from the limit_source label. + if tc.useOverride { + override := &database.UserAIBudgetOverride{GroupID: groupID, SpendLimitMicros: spendLimit} + expectTokenUsageCostLookups(db, intc, override, nil, nil, price) + } else { + group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimit} + expectTokenUsageCostLookups(db, intc, nil, group, nil, price) + } + db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( + func(fn func(database.Store) error, _ *database.TxOptions) error { return fn(db) }, + ) + db.EXPECT().InsertAIBridgeTokenUsage(gomock.Any(), gomock.Any()). + Return(database.AIBridgeTokenUsage{ID: uuid.New(), InterceptionID: intc.ID}, nil) + db.EXPECT().IncrementUserAIDailySpend(gomock.Any(), gomock.Any()). + Return(database.AIUserDailySpend{}, nil) + db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). + Return(database.GetUserAISpendSinceRow{SpendMicros: tc.newSpend}, nil) + db.EXPECT().GetGroupByID(gomock.Any(), groupID). + Return(database.Group{ID: groupID, Name: "Engineering"}, nil) + db.EXPECT().GetUserByID(gomock.Any(), intc.InitiatorID). + Return(database.User{ID: intc.InitiatorID, Username: "bob"}, nil) + db.EXPECT().GetUsers(gomock.Any(), database.GetUsersParams{ + RbacRole: []string{codersdk.RoleOwner, codersdk.RoleUserAdmin}, + }).Return([]database.GetUsersRow{admin, selfAdmin}, nil) + + ctx := testutil.Context(t, testutil.WaitLong) + srv, err := aibridgedserver.NewServer(ctx, aibridgedserver.Options{ + Store: db, + AISeatTracker: agplaiseats.Noop{}, + AccessURL: "/", + GatewayCfg: codersdk.AIBridgeConfig{}, + Experiments: requiredExperiments, + Enqueuer: enq, + Logger: testutil.Logger(t), + Clock: quartz.NewReal(), + }) + require.NoError(t, err) + + _, err = srv.RecordTokenUsage(ctx, &proto.RecordTokenUsageRequest{ + InterceptionId: intc.ID.String(), + MsgId: "msg_123", + InputTokens: tc.inputTokens, + CreatedAt: timestamppb.New(now), + }) + require.NoError(t, err) + + // The user who crossed the threshold gets the user-facing notification. + userSent := enq.Sent(notificationstest.WithTemplateID(tc.wantUserTemplate)) + require.Len(t, userSent, 1) + require.Equal(t, intc.InitiatorID, userSent[0].UserID) + + // The admin (but not the affected user, who is also an admin) gets the + // admin notification naming the affected user. + adminSent := enq.Sent(notificationstest.WithTemplateID(tc.wantAdminTemplate)) + require.Len(t, adminSent, 1) + require.Equal(t, admin.ID, adminSent[0].UserID) + require.Equal(t, "bob", adminSent[0].Labels["username"]) + require.Equal(t, tc.wantThreshold, adminSent[0].Labels["threshold"]) + require.Equal(t, "$100.00", adminSent[0].Labels["limit"]) + require.Equal(t, "Engineering", adminSent[0].Labels["effective_group_name"]) + require.Equal(t, tc.wantLimitSource, adminSent[0].Labels["limit_source"]) + }) + } +} + // newTestInterception returns an interception with a fixed initiator, provider, // and model for cost-attribution test setup. func newTestInterception(id uuid.UUID) database.AIBridgeInterception { diff --git a/coderd/aibridgedserver/cost.go b/coderd/aibridgedserver/cost.go index 79b59cd1b65fc..0d1dc35b747f6 100644 --- a/coderd/aibridgedserver/cost.go +++ b/coderd/aibridgedserver/cost.go @@ -12,6 +12,7 @@ import ( "github.com/coder/coder/v2/coderd/aibridge/budget" "github.com/coder/coder/v2/coderd/aibridged/proto" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) // tokensPerMillion is the divisor for prices, which are quoted per million @@ -24,6 +25,7 @@ const tokensPerMillion = 1_000_000 type tokenUsageCost struct { effectiveGroupID uuid.NullUUID spendLimitMicros sql.NullInt64 + limitSource codersdk.AIBudgetLimitSource inputPriceMicros sql.NullInt64 outputPriceMicros sql.NullInt64 cacheReadPriceMicros sql.NullInt64 @@ -54,9 +56,10 @@ func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBrid } else { result.effectiveGroupID = uuid.NullUUID{UUID: effectiveGroup.GroupID, Valid: true} // Limit is nil for the unlimited Everyone fallback; only a budgeted - // group carries the spend limit. + // group carries the spend limit and its source. if effectiveGroup.Limit != nil { result.spendLimitMicros = sql.NullInt64{Int64: effectiveGroup.Limit.SpendLimitMicros, Valid: true} + result.limitSource = effectiveGroup.Limit.Source } } diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index 4481401408f16..aa1fbd0d0ea3a 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -2,6 +2,7 @@ package aibridgedserver import ( "context" + "errors" "fmt" "strconv" "time" @@ -13,6 +14,7 @@ import ( "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 @@ -25,18 +27,27 @@ const ( // budgetNotificationsCreatedBy records what enqueued AI budget notifications. const budgetNotificationsCreatedBy = "aigateway" -// budgetThreshold pairs a percentage of the spend limit with the notification -// template sent when a user's spend crosses it. +// budgetThreshold pairs a percentage of the spend limit with the notifications +// sent when a user's spend crosses it. type budgetThreshold struct { - percent int - notificationTemplate uuid.UUID + 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, notificationTemplate: notifications.TemplateAIBudgetWarningUser}, - {percent: limitThresholdPercent, notificationTemplate: notifications.TemplateAIBudgetLimitReachedUser}, + { + 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 @@ -44,11 +55,13 @@ var budgetThresholds = []budgetThreshold{ // 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 - notificationTemplate uuid.UUID + 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 @@ -93,46 +106,100 @@ func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database 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, - notificationTemplate: t.notificationTemplate, - periodStart: period.Start, - periodEnd: period.End, + 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 } -// notifyBudgetThresholdCrossing enqueues the notification for the user who -// crossed the threshold. -func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing budgetThresholdCrossing) error { - group, err := s.store.GetGroupByID(ctx, crossing.effectiveGroupID) - if err != nil { - return xerrors.Errorf("look up group %q: %w", crossing.effectiveGroupID, err) +// 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 } - labels := map[string]string{ - "threshold": strconv.Itoa(crossing.thresholdPercent), - "limit": formatSpendLimit(crossing.spendLimitMicros), - "period": s.budgetPeriod.Adjective(), - "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": crossing.periodStart.UTC().Format("January 2, 2006"), - "period_end": crossing.periodEnd.UTC().Format("January 2, 2006"), + 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. - if _, err := s.notifEnqueuer.EnqueueWithData(dbauthz.AsNotifier(ctx), crossing.userID, crossing.notificationTemplate, - labels, nil, budgetNotificationsCreatedBy, - crossing.effectiveGroupID, - ); err != nil { - return xerrors.Errorf("enqueue notification: %w", err) + 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 nil + return recipients, nil } // formatSpendLimit renders a spend limit as a USD string. diff --git a/coderd/database/migrations/000553_ai_budget_admin_notifications.down.sql b/coderd/database/migrations/000553_ai_budget_admin_notifications.down.sql new file mode 100644 index 0000000000000..474b171d65a26 --- /dev/null +++ b/coderd/database/migrations/000553_ai_budget_admin_notifications.down.sql @@ -0,0 +1,5 @@ +DELETE FROM notification_templates +WHERE id IN ( + '2a7b0ac1-00e1-4625-9cd5-1e5933972c77', + '0bafe0ea-a78b-4217-ad05-1ef12e92e025' +); diff --git a/coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql b/coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql new file mode 100644 index 0000000000000..cdfd19bfec3d3 --- /dev/null +++ b/coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql @@ -0,0 +1,61 @@ +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) +VALUES ( + '2a7b0ac1-00e1-4625-9cd5-1e5933972c77', + 'AI Budget Warning (Admin)', + E'{{.Labels.username}} is approaching their {{.Labels.period}} AI budget limit', + $$User **{{.Labels.username}}** has used more than {{.Labels.threshold}}% of their {{.Labels.period}} AI budget ({{.Labels.limit}}). + +Effective group: **{{.Labels.effective_group_name}}** +{{- if eq .Labels.limit_source "user_override"}} + +This limit is a per-user override. +{{- end}} + +AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$, + '[]'::jsonb, + 'AI Cost Control Events', + NULL, + 'system'::notification_template_kind, + true +); + +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) +VALUES ( + '0bafe0ea-a78b-4217-ad05-1ef12e92e025', + 'AI Budget Limit Reached (Admin)', + E'{{.Labels.username}} has reached their {{.Labels.period}} AI budget limit', + $$User **{{.Labels.username}}** has reached their {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. + +Effective group: **{{.Labels.effective_group_name}}** +{{- if eq .Labels.limit_source "user_override"}} + +This limit is a per-user override. +{{- end}} + +AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$, + '[]'::jsonb, + 'AI Cost Control Events', + NULL, + 'system'::notification_template_kind, + true +); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index c2885cc98660e..73c9eef3cd58e 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -72,6 +72,8 @@ var ( // AI cost control related events. var ( - TemplateAIBudgetWarningUser = uuid.MustParse("b5db9597-de2a-4dea-87e9-25cee6906b86") - TemplateAIBudgetLimitReachedUser = uuid.MustParse("cdcf2ecd-f003-4169-9800-abb2661ea522") + TemplateAIBudgetWarningUser = uuid.MustParse("b5db9597-de2a-4dea-87e9-25cee6906b86") + TemplateAIBudgetLimitReachedUser = uuid.MustParse("cdcf2ecd-f003-4169-9800-abb2661ea522") + TemplateAIBudgetWarningAdmin = uuid.MustParse("2a7b0ac1-00e1-4625-9cd5-1e5933972c77") + TemplateAIBudgetLimitReachedAdmin = uuid.MustParse("0bafe0ea-a78b-4217-ad05-1ef12e92e025") ) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index ac26ecca9fa61..2c76d0e2df40a 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1497,6 +1497,45 @@ func TestNotificationTemplates_Golden(t *testing.T) { Data: map[string]any{}, }, }, + { + name: "TemplateAIBudgetWarningAdmin", + id: notifications.TemplateAIBudgetWarningAdmin, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "username": "alice", + "threshold": "85", + "limit": "$1000.00", + "period": "monthly", + "limit_source": "group", + "effective_group_name": "Engineering", + "period_start": "July 1, 2026", + "period_end": "August 1, 2026", + }, + Data: map[string]any{}, + }, + }, + { + name: "TemplateAIBudgetLimitReachedAdmin", + id: notifications.TemplateAIBudgetLimitReachedAdmin, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "username": "alice", + "limit": "$1000.00", + "period": "monthly", + "limit_source": "user_override", + "effective_group_name": "Engineering", + "period_start": "July 1, 2026", + "period_end": "August 1, 2026", + }, + Data: map[string]any{}, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden new file mode 100644 index 0000000000000..550fd9b56fd03 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden @@ -0,0 +1,82 @@ +From: system@coder.com +To: bobby@coder.com +Subject: alice has reached their monthly AI budget limit +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +User alice has reached their monthly AI budget limit ($1000.00). Subsequent= + requests will be blocked. + +Effective group: Engineering + +This limit is a per-user override. + +AI budget period: July 1, 2026 - August 1, 2026 + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Codestin Search App + + +
+
+ 3D"Cod= +
+

+ alice has reached their monthly AI budget limit +

+
+

Hi Bobby,

+

User alice has reached their monthly AI budget = +limit ($1000.00). Subsequent requests will be blocked.

+ +

Effective group: Engineering

+ +

This limit is a per-user override.

+ +

AI budget period: July 1, 2026 - August 1, 2026

+
+
+ =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningAdmin.html.golden new file mode 100644 index 0000000000000..87937d7fcd43b --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningAdmin.html.golden @@ -0,0 +1,77 @@ +From: system@coder.com +To: bobby@coder.com +Subject: alice is approaching their monthly AI budget limit +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +User alice has used more than 85% of their monthly AI budget ($1000.00). + +Effective group: Engineering + +AI budget period: July 1, 2026 - August 1, 2026 + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Codestin Search App + + +
+
+ 3D"Cod= +
+

+ alice is approaching their monthly AI budget limit +

+
+

Hi Bobby,

+

User alice has used more than 85% of their mont= +hly AI budget ($1000.00).

+ +

Effective group: Engineering

+ +

AI budget period: July 1, 2026 - August 1, 2026

+
+
+ =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden new file mode 100644 index 0000000000000..5422ca4067162 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden @@ -0,0 +1,29 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "AI Budget Limit Reached (Admin)", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [], + "labels": { + "effective_group_name": "Engineering", + "limit": "$1000.00", + "limit_source": "user_override", + "period": "monthly", + "period_end": "August 1, 2026", + "period_start": "July 1, 2026", + "username": "alice" + }, + "data": {}, + "targets": null + }, + "title": "alice has reached their monthly AI budget limit", + "title_markdown": "alice has reached their monthly AI budget limit", + "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026", + "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026" +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden new file mode 100644 index 0000000000000..783db674653b2 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden @@ -0,0 +1,30 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "AI Budget Warning (Admin)", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [], + "labels": { + "effective_group_name": "Engineering", + "limit": "$1000.00", + "limit_source": "group", + "period": "monthly", + "period_end": "August 1, 2026", + "period_start": "July 1, 2026", + "threshold": "85", + "username": "alice" + }, + "data": {}, + "targets": null + }, + "title": "alice is approaching their monthly AI budget limit", + "title_markdown": "alice is approaching their monthly AI budget limit", + "body": "User alice has used more than 85% of their monthly AI budget ($1000.00).\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026", + "body_markdown": "User **alice** has used more than 85% of their monthly AI budget ($1000.00).\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026" +} \ No newline at end of file From 73b0129c50bc1a447b542cde74f470ce9f0c3ee6 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 27 Jul 2026 19:29:34 +0000 Subject: [PATCH 2/3] chore: raise budget notification log to error --- coderd/aibridgedserver/aibridgedserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 3b217e9a15e43..f2dee9a6b3b4c 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -435,7 +435,7 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB } if err := s.notifyBudgetThresholdCrossings(ctx, crossings); err != nil { - s.logger.Warn(ctx, "failed to send AI budget notifications", + s.logger.Error(ctx, "failed to send AI budget notifications", slog.F("initiator_id", intc.InitiatorID), slog.Error(err)) } From 1f904cf1b90655e4307cac718b7a0c5dbcb0cfce Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 27 Jul 2026 16:36:10 -0400 Subject: [PATCH 3/3] fix: hide admin AI budget notification preferences from members (#27546) --- .../000553_ai_budget_admin_notifications.up.sql | 8 ++++---- .../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 2 +- .../webhook/TemplateAIBudgetWarningAdmin.json.golden | 2 +- .../NotificationsPage/NotificationsPage.tsx | 2 ++ 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql b/coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql index cdfd19bfec3d3..4cb57c4727ed9 100644 --- a/coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql +++ b/coderd/database/migrations/000553_ai_budget_admin_notifications.up.sql @@ -11,7 +11,7 @@ INSERT INTO notification_templates ( ) VALUES ( '2a7b0ac1-00e1-4625-9cd5-1e5933972c77', - 'AI Budget Warning (Admin)', + 'User Approaching AI Budget Limit', E'{{.Labels.username}} is approaching their {{.Labels.period}} AI budget limit', $$User **{{.Labels.username}}** has used more than {{.Labels.threshold}}% of their {{.Labels.period}} AI budget ({{.Labels.limit}}). @@ -23,7 +23,7 @@ This limit is a per-user override. AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$, '[]'::jsonb, - 'AI Cost Control Events', + 'AI Cost Control Admin Events', NULL, 'system'::notification_template_kind, true @@ -42,7 +42,7 @@ INSERT INTO notification_templates ( ) VALUES ( '0bafe0ea-a78b-4217-ad05-1ef12e92e025', - 'AI Budget Limit Reached (Admin)', + 'User Reached AI Budget Limit', E'{{.Labels.username}} has reached their {{.Labels.period}} AI budget limit', $$User **{{.Labels.username}}** has reached their {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. @@ -54,7 +54,7 @@ This limit is a per-user override. AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$, '[]'::jsonb, - 'AI Cost Control Events', + 'AI Cost Control Admin Events', NULL, 'system'::notification_template_kind, true diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden index 5422ca4067162..4315def76659e 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden @@ -3,7 +3,7 @@ "msg_id": "00000000-0000-0000-0000-000000000000", "payload": { "_version": "1.2", - "notification_name": "AI Budget Limit Reached (Admin)", + "notification_name": "User Reached AI Budget Limit", "notification_template_id": "00000000-0000-0000-0000-000000000000", "user_id": "00000000-0000-0000-0000-000000000000", "user_email": "bobby@coder.com", diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden index 783db674653b2..6e83fd5d71c08 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningAdmin.json.golden @@ -3,7 +3,7 @@ "msg_id": "00000000-0000-0000-0000-000000000000", "payload": { "_version": "1.2", - "notification_name": "AI Budget Warning (Admin)", + "notification_name": "User Approaching AI Budget Limit", "notification_template_id": "00000000-0000-0000-0000-000000000000", "user_id": "00000000-0000-0000-0000-000000000000", "user_email": "bobby@coder.com", diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 8910e875a0bbb..85fa0bdab34d1 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -287,6 +287,8 @@ function canSeeNotificationGroup( case "Custom Events": case "AI Cost Control Events": return true; + case "AI Cost Control Admin Events": + return permissions.createUser; default: return false; }