From 239eaf096cd11c4a598b3e563de598d1df396fe1 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 20 Jul 2026 15:01:10 +0000 Subject: [PATCH 01/25] feat: notify users when AI spend crosses the budget threshold --- coderd/aibridged.go | 1 + coderd/aibridgedserver/aibridgedserver.go | 49 +++++- .../aibridgedserver/aibridgedserver_test.go | 142 ++++++++++++++++++ coderd/aibridgedserver/cost.go | 2 + coderd/aibridgedserver/notifications.go | 102 +++++++++++++ .../000548_ai_budget_notifications.down.sql | 2 + .../000548_ai_budget_notifications.up.sql | 22 +++ coderd/notifications/events.go | 5 + coderd/notifications/notifications_test.go | 15 ++ .../TemplateAIBudgetWarningUser.html.golden | 70 +++++++++ .../TemplateAIBudgetWarningUser.json.golden | 25 +++ enterprise/coderd/aibridgeserve.go | 1 + 12 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 coderd/aibridgedserver/notifications.go create mode 100644 coderd/database/migrations/000548_ai_budget_notifications.down.sql create mode 100644 coderd/database/migrations/000548_ai_budget_notifications.up.sql create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden diff --git a/coderd/aibridged.go b/coderd/aibridged.go index d163fbfe091..2a88fc53fc0 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -69,6 +69,7 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai Store: api.Database, Pubsub: api.Pubsub, AISeatTracker: api.AISeatTracker, + Enqueuer: api.NotificationsEnqueuer, AccessURL: api.AccessURL.String(), GatewayCfg: api.DeploymentValues.AI.BridgeConfig, ExternalAuthConfigs: api.ExternalAuthConfigs, diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index c17a0879200..633820d1c75 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -30,6 +30,7 @@ import ( "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpmw" codermcp "github.com/coder/coder/v2/coderd/mcp" + "github.com/coder/coder/v2/coderd/notifications" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -81,6 +82,7 @@ type store interface { GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) GetUserAISpendSince(ctx context.Context, arg database.GetUserAISpendSinceParams) (database.GetUserAISpendSinceRow, error) + GetGroupByID(ctx context.Context, id uuid.UUID) (database.Group, error) // MCPConfigurator-related queries. GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.ExternalAuthLink, error) @@ -118,6 +120,9 @@ type Server struct { // derive the window over which user AI spend is aggregated. budgetPeriod codersdk.AIBudgetPeriod clock quartz.Clock + // notifEnqueuer enqueues notifications. It is never nil; NewServer defaults + // it to a no-op enqueuer. + notifEnqueuer notifications.Enqueuer } // Options carries the dependencies required to construct an aibridged Server. @@ -125,6 +130,9 @@ type Options struct { Store store Pubsub pubsub.Pubsub AISeatTracker aiseats.SeatTracker + // Enqueuer enqueues notifications. When nil, NewServer substitutes a no-op + // enqueuer. + Enqueuer notifications.Enqueuer AccessURL string GatewayCfg codersdk.AIBridgeConfig @@ -136,6 +144,11 @@ type Options struct { } func NewServer(lifecycleCtx context.Context, opts Options) (*Server, error) { + enqueuer := opts.Enqueuer + if enqueuer == nil { + enqueuer = notifications.NewNoopEnqueuer() + } + eac := make(map[string]*externalauth.Config, len(opts.ExternalAuthConfigs)) for _, cfg := range opts.ExternalAuthConfigs { @@ -157,6 +170,7 @@ func NewServer(lifecycleCtx context.Context, opts Options) (*Server, error) { budgetPolicy: codersdk.NewAIBudgetPolicyFromString(opts.GatewayCfg.BudgetPolicy), budgetPeriod: codersdk.NewAIBudgetPeriodFromString(opts.GatewayCfg.BudgetPeriod), clock: opts.Clock, + notifEnqueuer: enqueuer, } if opts.GatewayCfg.InjectCoderMCPTools { @@ -356,7 +370,14 @@ func (s *Server) RecordTokenUsage(ctx context.Context, in *proto.RecordTokenUsag // positive, accumulates that cost into the user's daily spend. func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIBridgeInterception, cost tokenUsageCost, in *proto.RecordTokenUsageRequest, metadataJSON []byte) error { createdAt := in.GetCreatedAt().AsTime() - return s.store.InTx(func(tx database.Store) error { + + // Populated inside the transaction when this interception crosses a budget + // threshold. + var ( + crossing budgetThresholdCrossing + crossed bool + ) + err := s.store.InTx(func(tx database.Store) error { if _, err := tx.InsertAIBridgeTokenUsage(ctx, database.InsertAIBridgeTokenUsageParams{ ID: uuid.New(), InterceptionID: intc.ID, @@ -399,8 +420,34 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB }); err != nil { return xerrors.Errorf("increment user daily spend: %w", err) } + + // Threshold detection is best-effort: a failed read must not roll back + // the committed spend, so the error is logged rather than propagated. + var detectErr error + crossing, crossed, detectErr = s.detectBudgetThresholdCrossing(ctx, tx, intc, cost) + if detectErr != nil { + // crossed is false on error; log and continue so a failed read does + // not roll back the committed spend. + s.logger.Warn(ctx, "failed to detect AI budget threshold crossing", + slog.F("interception_id", intc.ID), + slog.F("initiator_id", intc.InitiatorID), + slog.Error(detectErr)) + } return nil }, nil) + if err != nil { + return err + } + + if crossed { + if err := s.notifyBudgetThresholdCrossing(ctx, crossing); err != nil { + s.logger.Warn(ctx, "failed to send AI budget warning notification", + slog.F("user_id", crossing.userID), + slog.F("group_id", crossing.groupID), + slog.Error(err)) + } + } + return nil } func (s *Server) RecordPromptUsage(ctx context.Context, in *proto.RecordPromptUsageRequest) (*proto.RecordPromptUsageResponse, error) { diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index bf28dbdf9ed..9d7a5445129 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -44,6 +44,8 @@ import ( "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" codermcp "github.com/coder/coder/v2/coderd/mcp" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/notificationstest" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" @@ -1663,6 +1665,9 @@ func TestRecordTokenUsage(t *testing.T) { Day: now.UTC().Truncate(24 * time.Hour), CostMicros: wantCost, }).Return(database.AIUserDailySpend{}, nil) + + db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). + Return(database.GetUserAISpendSinceRow{SpendMicros: wantCost}, nil) }, }, { @@ -1715,6 +1720,9 @@ func TestRecordTokenUsage(t *testing.T) { Day: now.UTC().Truncate(24 * time.Hour), CostMicros: wantCost, }).Return(database.AIUserDailySpend{}, nil) + + db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). + Return(database.GetUserAISpendSinceRow{SpendMicros: wantCost}, nil) }, }, { @@ -2201,6 +2209,140 @@ func TestRecordTokenUsageAuthorized(t *testing.T) { require.Equal(t, wantCost, spend.SpendMicros, "spend micros") } +// TestRecordTokenUsageBudgetWarningNotification verifies that recording token +// usage that pushes the user's period spend across the 90% warning threshold +// enqueues a warning notification to the user, and that staying below the +// threshold does not. +func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { + t.Parallel() + + const ( + spendLimitMicros int64 = 1_000_000 // $1 limit + warnAtMicros int64 = 900_000 // 90% of the limit + inputTokens int64 = 1000 + inputPriceMicros int64 = 1_000_000 // $1 per million tokens + ) + now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) + + // inputTokens * inputPriceMicros / 1e6 -> this interception costs 1000 + // micros. newSpend is the post-increment period total; the code derives the + // pre-increment total as newSpend - 1000 and fires a warning only when it + // crosses warnAtMicros (pre < warnAtMicros <= post). + price := &database.AIModelPrice{ + InputPrice: sql.NullInt64{Int64: inputPriceMicros, Valid: true}, + } + + testCases := []struct { + name string + newSpend int64 + wantNotified bool + wantThreshold string + wantLimit string + }{ + { + name: "crosses warning threshold", + // pre = 900_500 - 1000 = 899_500 (< 900_000) + // post = 900_500 (>= 900_000) -> crosses. + newSpend: warnAtMicros + 500, + wantNotified: true, + wantThreshold: "90", + wantLimit: "$1.00", + }, + { + name: "crosses when post lands exactly on threshold", + // pre = 900_000 - 1000 = 899_000 (< 900_000) + // post = 900_000 (>= 900_000) -> crosses. + newSpend: warnAtMicros, + wantNotified: true, + wantThreshold: "90", + wantLimit: "$1.00", + }, + { + name: "stays below warning threshold", + // pre = 899_999 - 1000 = 898_999 (< 900_000) + // post = 899_999 (< 900_000) -> no crossing. + newSpend: warnAtMicros - 1, + wantNotified: false, + }, + { + name: "already at warning threshold", + // pre = 901_000 - 1000 = 900_000 (not < 900_000) + // post = 901_000 -> no fresh crossing. + newSpend: warnAtMicros + 1000, + wantNotified: false, + }, + { + name: "already above warning threshold", + // pre = 910_000 - 1000 = 909_000 (>= 900_000) + // post = 910_000 -> no fresh crossing. + newSpend: warnAtMicros + 10000, + wantNotified: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + enq := ¬ificationstest.FakeEnqueuer{} + + intc := newTestInterception(uuid.New()) + groupID := uuid.New() + group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimitMicros} + + expectTokenUsageCostLookups(db, intc, nil, group, 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) + if tc.wantNotified { + db.EXPECT().GetGroupByID(gomock.Any(), groupID). + Return(database.Group{ID: groupID, Name: "Engineering"}, 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: 1000, + CreatedAt: timestamppb.New(now), + }) + require.NoError(t, err) + + sent := enq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIBudgetWarningUser)) + if !tc.wantNotified { + require.Empty(t, sent, "no budget warning notification expected") + return + } + require.Len(t, sent, 1, "expected one budget warning notification") + require.Equal(t, intc.InitiatorID, sent[0].UserID) + require.Equal(t, tc.wantThreshold, sent[0].Labels["threshold"]) + require.Equal(t, tc.wantLimit, sent[0].Labels["limit"]) + require.Equal(t, "Engineering", sent[0].Labels["group_name"]) + }) + } +} + // 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 0a6b9ba5b63..3952da163f7 100644 --- a/coderd/aibridgedserver/cost.go +++ b/coderd/aibridgedserver/cost.go @@ -23,6 +23,7 @@ const tokensPerMillion = 1_000_000 // price or cost of 0 is recorded as 0, which is distinct from NULL. type tokenUsageCost struct { effectiveGroupID uuid.NullUUID + spendLimitMicros sql.NullInt64 inputPriceMicros sql.NullInt64 outputPriceMicros sql.NullInt64 cacheReadPriceMicros sql.NullInt64 @@ -47,6 +48,7 @@ func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBrid } if ok { result.effectiveGroupID = uuid.NullUUID{UUID: effectiveBudget.GroupID, Valid: true} + result.spendLimitMicros = sql.NullInt64{Int64: effectiveBudget.SpendLimitMicros, Valid: true} } // Snapshot the price for this (provider, model) and compute cost. diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go new file mode 100644 index 00000000000..02379d8d861 --- /dev/null +++ b/coderd/aibridgedserver/notifications.go @@ -0,0 +1,102 @@ +package aibridgedserver + +import ( + "context" + "fmt" + "strconv" + + "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" +) + +// warningThresholdPercent is the percentage of the spend limit that triggers a +// warning notification. +const warningThresholdPercent = 90 + +// budgetNotificationsCreatedBy records what enqueued AI budget notifications. +const budgetNotificationsCreatedBy = "aigateway" + +// 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 + groupID uuid.UUID + spendLimitMicros int64 + thresholdPercent int +} + +// detectBudgetThresholdCrossing reports whether recording this interception's +// cost pushed the user's period spend across the warning threshold. +func (s *Server) detectBudgetThresholdCrossing(ctx context.Context, tx database.Store, intc database.AIBridgeInterception, cost tokenUsageCost) (budgetThresholdCrossing, bool, error) { + if !cost.effectiveGroupID.Valid || !cost.spendLimitMicros.Valid || cost.spendLimitMicros.Int64 <= 0 { + return budgetThresholdCrossing{}, false, nil + } + + period, err := budget.CurrentPeriod(s.clock.Now(), s.budgetPeriod) + if err != nil { + return budgetThresholdCrossing{}, false, 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 budgetThresholdCrossing{}, false, 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 + + warnAt := limit * warningThresholdPercent / 100 + if oldSpend < warnAt && newSpend >= warnAt { + return budgetThresholdCrossing{ + userID: intc.InitiatorID, + groupID: cost.effectiveGroupID.UUID, + spendLimitMicros: limit, + thresholdPercent: warningThresholdPercent, + }, true, nil + } + return budgetThresholdCrossing{}, false, nil +} + +// notifyBudgetThresholdCrossing enqueues the warning notification for the user +// who crossed the threshold. +func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing budgetThresholdCrossing) error { + //nolint:gocritic // The interception context is scoped to AI Bridge; reading the group and enqueuing need system access. + sysCtx := dbauthz.AsSystemRestricted(ctx) + + group, err := s.store.GetGroupByID(sysCtx, crossing.groupID) + if err != nil { + return xerrors.Errorf("look up group %q: %w", crossing.groupID, err) + } + + labels := map[string]string{ + "threshold": strconv.Itoa(crossing.thresholdPercent), + "limit": formatSpendLimit(crossing.spendLimitMicros), + "group_name": group.Name, + } + + if _, err := s.notifEnqueuer.EnqueueWithData(sysCtx, crossing.userID, notifications.TemplateAIBudgetWarningUser, + labels, nil, budgetNotificationsCreatedBy, + crossing.groupID, + ); err != nil { + return xerrors.Errorf("enqueue notification: %w", err) + } + return nil +} + +// formatSpendLimit renders a spend limit as a USD string. +func formatSpendLimit(micros int64) string { + return fmt.Sprintf("$%.2f", float64(micros)/1_000_000) +} diff --git a/coderd/database/migrations/000548_ai_budget_notifications.down.sql b/coderd/database/migrations/000548_ai_budget_notifications.down.sql new file mode 100644 index 00000000000..7d971ace7fe --- /dev/null +++ b/coderd/database/migrations/000548_ai_budget_notifications.down.sql @@ -0,0 +1,2 @@ +DELETE FROM notification_templates +WHERE id = 'b5db9597-de2a-4dea-87e9-25cee6906b86'; diff --git a/coderd/database/migrations/000548_ai_budget_notifications.up.sql b/coderd/database/migrations/000548_ai_budget_notifications.up.sql new file mode 100644 index 00000000000..86ba51230d6 --- /dev/null +++ b/coderd/database/migrations/000548_ai_budget_notifications.up.sql @@ -0,0 +1,22 @@ +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) +VALUES ( + 'b5db9597-de2a-4dea-87e9-25cee6906b86', + 'AI Budget Warning Threshold Reached', + E'You''re approaching your monthly AI budget limit', + E'You have used more than {{.Labels.threshold}}% of your monthly AI budget ({{.Labels.limit}}), set by **{{.Labels.group_name}}**.', + '[]'::jsonb, + 'AI Budget', + NULL, + 'system'::notification_template_kind, + true +); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index a2da202702b..e5d54ef2faf 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -69,3 +69,8 @@ var ( TemplateChatAutoArchiveDigest = uuid.MustParse("764031be-4863-4220-867b-6ce1a1b7a5f5") TemplateChatShared = uuid.MustParse("b789bd75-d7c6-4cab-9757-1147ab184903") ) + +// AI budget-related events. +var ( + TemplateAIBudgetWarningUser = uuid.MustParse("b5db9597-de2a-4dea-87e9-25cee6906b86") +) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 7b7b5daa32b..b4d84dffded 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1461,6 +1461,21 @@ func TestNotificationTemplates_Golden(t *testing.T) { }, }, }, + { + name: "TemplateAIBudgetWarningUser", + id: notifications.TemplateAIBudgetWarningUser, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "threshold": "90", + "limit": "$1000.00", + "group_name": "Engineering", + }, + 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/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden new file mode 100644 index 00000000000..1bf4c72cb84 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -0,0 +1,70 @@ +From: system@coder.com +To: bobby@coder.com +Subject: You're approaching your 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, + +You have used more than 90% of your monthly AI budget ($1000.00), set by En= +gineering. + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Codestin Search App + + +
+
+ 3D"Cod= +
+

+ You're approaching your monthly AI budget limit +

+
+

Hi Bobby,

+

You have used more than 90% of your monthly AI budget ($1000.00)= +, set by Engineering.

+
+
+ =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/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden new file mode 100644 index 00000000000..3140f23b821 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -0,0 +1,25 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "AI Budget Warning Threshold Reached", + "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": { + "group_name": "Engineering", + "limit": "$1000.00", + "threshold": "90" + }, + "data": {}, + "targets": null + }, + "title": "You're approaching your monthly AI budget limit", + "title_markdown": "You're approaching your monthly AI budget limit", + "body": "You have used more than 90% of your monthly AI budget ($1000.00), set by Engineering.", + "body_markdown": "You have used more than 90% of your monthly AI budget ($1000.00), set by **Engineering**." +} \ No newline at end of file diff --git a/enterprise/coderd/aibridgeserve.go b/enterprise/coderd/aibridgeserve.go index 14663c4610d..ff6435f0411 100644 --- a/enterprise/coderd/aibridgeserve.go +++ b/enterprise/coderd/aibridgeserve.go @@ -138,6 +138,7 @@ func (api *API) aiGatewayServe(rw http.ResponseWriter, r *http.Request) { Store: api.Database, Pubsub: api.AGPL.Pubsub, AISeatTracker: api.AGPL.AISeatTracker, + Enqueuer: api.AGPL.NotificationsEnqueuer, AccessURL: api.AccessURL.String(), GatewayCfg: api.DeploymentValues.AI.BridgeConfig, ExternalAuthConfigs: api.ExternalAuthConfigs, From 90ab01503568b4d3b8045532b89f1e890b5f2018 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Tue, 21 Jul 2026 16:28:54 -0400 Subject: [PATCH 02/25] fix: show AI budget notification in user settings and clarify its text (#27397) --- coderd/aibridgedserver/aibridgedserver.go | 19 +-- .../aibridgedserver/aibridgedserver_test.go | 138 ++++++++++++++++-- coderd/aibridgedserver/notifications.go | 66 ++++++--- .../000548_ai_budget_notifications.down.sql | 5 +- .../000548_ai_budget_notifications.up.sql | 25 +++- coderd/notifications/events.go | 3 +- coderd/notifications/notifications_test.go | 16 +- ...mplateAIBudgetLimitReachedUser.html.golden | 71 +++++++++ .../TemplateAIBudgetWarningUser.html.golden | 8 +- ...mplateAIBudgetLimitReachedUser.json.golden | 24 +++ .../TemplateAIBudgetWarningUser.json.golden | 6 +- .../NotificationsPage/NotificationsPage.tsx | 1 + 12 files changed, 325 insertions(+), 57 deletions(-) create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 633820d1c75..9f0d3f66425 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -371,12 +371,9 @@ func (s *Server) RecordTokenUsage(ctx context.Context, in *proto.RecordTokenUsag func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIBridgeInterception, cost tokenUsageCost, in *proto.RecordTokenUsageRequest, metadataJSON []byte) error { createdAt := in.GetCreatedAt().AsTime() - // Populated inside the transaction when this interception crosses a budget - // threshold. - var ( - crossing budgetThresholdCrossing - crossed bool - ) + // Populated inside the transaction with any budget thresholds this + // interception crossed. + var crossings []budgetThresholdCrossing err := s.store.InTx(func(tx database.Store) error { if _, err := tx.InsertAIBridgeTokenUsage(ctx, database.InsertAIBridgeTokenUsageParams{ ID: uuid.New(), @@ -424,10 +421,9 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB // Threshold detection is best-effort: a failed read must not roll back // the committed spend, so the error is logged rather than propagated. var detectErr error - crossing, crossed, detectErr = s.detectBudgetThresholdCrossing(ctx, tx, intc, cost) + crossings, detectErr = s.detectBudgetThresholdCrossings(ctx, tx, intc, cost) if detectErr != nil { - // crossed is false on error; log and continue so a failed read does - // not roll back the committed spend. + // log and continue so a failed read does not roll back the committed spend. s.logger.Warn(ctx, "failed to detect AI budget threshold crossing", slog.F("interception_id", intc.ID), slog.F("initiator_id", intc.InitiatorID), @@ -439,11 +435,12 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB return err } - if crossed { + for _, crossing := range crossings { if err := s.notifyBudgetThresholdCrossing(ctx, crossing); err != nil { - s.logger.Warn(ctx, "failed to send AI budget warning notification", + s.logger.Warn(ctx, "failed to send AI budget notification", slog.F("user_id", crossing.userID), slog.F("group_id", crossing.groupID), + slog.F("threshold_percent", crossing.thresholdPercent), slog.Error(err)) } } diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 9d7a5445129..1e808663e81 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2210,7 +2210,7 @@ func TestRecordTokenUsageAuthorized(t *testing.T) { } // TestRecordTokenUsageBudgetWarningNotification verifies that recording token -// usage that pushes the user's period spend across the 90% warning threshold +// usage that pushes the user's period spend across the 85% warning threshold // enqueues a warning notification to the user, and that staying below the // threshold does not. func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { @@ -2218,7 +2218,7 @@ func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { const ( spendLimitMicros int64 = 1_000_000 // $1 limit - warnAtMicros int64 = 900_000 // 90% of the limit + warnAtMicros int64 = 850_000 // 85% of the limit inputTokens int64 = 1000 inputPriceMicros int64 = 1_000_000 // $1 per million tokens ) @@ -2241,40 +2241,40 @@ func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { }{ { name: "crosses warning threshold", - // pre = 900_500 - 1000 = 899_500 (< 900_000) - // post = 900_500 (>= 900_000) -> crosses. + // pre = 850_500 - 1000 = 849_500 (< 850_000) + // post = 850_500 (>= 850_000) -> crosses. newSpend: warnAtMicros + 500, wantNotified: true, - wantThreshold: "90", + wantThreshold: "85", wantLimit: "$1.00", }, { name: "crosses when post lands exactly on threshold", - // pre = 900_000 - 1000 = 899_000 (< 900_000) - // post = 900_000 (>= 900_000) -> crosses. + // pre = 850_000 - 1000 = 849_000 (< 850_000) + // post = 850_000 (>= 850_000) -> crosses. newSpend: warnAtMicros, wantNotified: true, - wantThreshold: "90", + wantThreshold: "85", wantLimit: "$1.00", }, { name: "stays below warning threshold", - // pre = 899_999 - 1000 = 898_999 (< 900_000) - // post = 899_999 (< 900_000) -> no crossing. + // pre = 849_999 - 1000 = 848_999 (< 850_000) + // post = 849_999 (< 850_000) -> no crossing. newSpend: warnAtMicros - 1, wantNotified: false, }, { name: "already at warning threshold", - // pre = 901_000 - 1000 = 900_000 (not < 900_000) - // post = 901_000 -> no fresh crossing. + // pre = 851_000 - 1000 = 850_000 (not < 850_000) + // post = 851_000 -> no fresh crossing. newSpend: warnAtMicros + 1000, wantNotified: false, }, { name: "already above warning threshold", - // pre = 910_000 - 1000 = 909_000 (>= 900_000) - // post = 910_000 -> no fresh crossing. + // pre = 860_000 - 1000 = 859_000 (>= 850_000) + // post = 860_000 -> no fresh crossing. newSpend: warnAtMicros + 10000, wantNotified: false, }, @@ -2343,6 +2343,116 @@ func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { } } +// TestRecordTokenUsageBudgetLimitReachedNotification verifies that crossing the +// 100% limit enqueues the limit-reached notification, and that a single +// interception crossing both the warning and limit thresholds enqueues both. +func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { + t.Parallel() + + const spendLimitMicros int64 = 1_000_000 // $1 limit (100% threshold) + now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) + + // $1 per million tokens means the interception cost in micros equals the + // input token count, so each case sets its cost via inputTokens. + price := &database.AIModelPrice{ + InputPrice: sql.NullInt64{Int64: 1_000_000, Valid: true}, + } + + testCases := []struct { + name string + inputTokens int64 // also the interception cost in micros + newSpend int64 // post-increment period total + wantTemplates []uuid.UUID + }{ + { + name: "crosses limit", + // pre = 999_500 (>= 850_000, so no warning; < 1_000_000) + // post = 1_000_500 (>= 1_000_000) -> limit only. + inputTokens: 1000, + newSpend: spendLimitMicros + 500, + wantTemplates: []uuid.UUID{notifications.TemplateAIBudgetLimitReachedUser}, + }, + { + name: "crosses warning and limit in one interception", + // pre = 1_000_000 - 200_000 = 800_000 (< 850_000) + // post = 1_000_000 (>= 850_000 and >= 1_000_000) -> warning + limit. + inputTokens: 200_000, + newSpend: spendLimitMicros, + wantTemplates: []uuid.UUID{ + notifications.TemplateAIBudgetWarningUser, + notifications.TemplateAIBudgetLimitReachedUser, + }, + }, + { + name: "already above limit", + // pre = 1_009_000 (>= 1_000_000) -> no fresh crossing. + inputTokens: 1000, + newSpend: spendLimitMicros + 10_000, + wantTemplates: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + enq := ¬ificationstest.FakeEnqueuer{} + + intc := newTestInterception(uuid.New()) + groupID := uuid.New() + group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimitMicros} + + expectTokenUsageCostLookups(db, intc, nil, group, 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) + // 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)) + + 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) + + require.Len(t, enq.Sent(), len(tc.wantTemplates), "unexpected number of notifications") + for _, tmpl := range tc.wantTemplates { + sent := enq.Sent(notificationstest.WithTemplateID(tmpl)) + require.Len(t, sent, 1, "expected one notification for template %s", tmpl) + require.Equal(t, intc.InitiatorID, sent[0].UserID) + require.Equal(t, "$1.00", sent[0].Labels["limit"]) + require.Equal(t, "Engineering", sent[0].Labels["group_name"]) + } + }) + } +} + // 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/notifications.go b/coderd/aibridgedserver/notifications.go index 02379d8d861..b46592be224 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -14,13 +14,30 @@ import ( "github.com/coder/coder/v2/coderd/notifications" ) -// warningThresholdPercent is the percentage of the spend limit that triggers a -// warning notification. -const warningThresholdPercent = 90 +// 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 notification +// template sent when a user's spend crosses it. +type budgetThreshold struct { + percent int + template 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, template: notifications.TemplateAIBudgetWarningUser}, + {percent: limitThresholdPercent, template: notifications.TemplateAIBudgetLimitReachedUser}, +} + // 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 @@ -30,18 +47,21 @@ type budgetThresholdCrossing struct { groupID uuid.UUID spendLimitMicros int64 thresholdPercent int + template uuid.UUID } -// detectBudgetThresholdCrossing reports whether recording this interception's -// cost pushed the user's period spend across the warning threshold. -func (s *Server) detectBudgetThresholdCrossing(ctx context.Context, tx database.Store, intc database.AIBridgeInterception, cost tokenUsageCost) (budgetThresholdCrossing, bool, error) { +// 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). +func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database.Store, intc database.AIBridgeInterception, cost tokenUsageCost) ([]budgetThresholdCrossing, error) { if !cost.effectiveGroupID.Valid || !cost.spendLimitMicros.Valid || cost.spendLimitMicros.Int64 <= 0 { - return budgetThresholdCrossing{}, false, nil + return nil, nil } period, err := budget.CurrentPeriod(s.clock.Now(), s.budgetPeriod) if err != nil { - return budgetThresholdCrossing{}, false, xerrors.Errorf("compute AI budget period: %w", err) + return nil, xerrors.Errorf("compute AI budget period: %w", err) } spend, err := tx.GetUserAISpendSince(ctx, database.GetUserAISpendSinceParams{ @@ -50,7 +70,7 @@ func (s *Server) detectBudgetThresholdCrossing(ctx context.Context, tx database. PeriodStart: period.Start, }) if err != nil { - return budgetThresholdCrossing{}, false, xerrors.Errorf("get user AI spend for user %q in group %q: %w", intc.InitiatorID, cost.effectiveGroupID.UUID, err) + 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 @@ -58,20 +78,24 @@ func (s *Server) detectBudgetThresholdCrossing(ctx context.Context, tx database. // Pre-interception total is the current total minus this interception's cost. oldSpend := newSpend - cost.costMicros.Int64 - warnAt := limit * warningThresholdPercent / 100 - if oldSpend < warnAt && newSpend >= warnAt { - return budgetThresholdCrossing{ - userID: intc.InitiatorID, - groupID: cost.effectiveGroupID.UUID, - spendLimitMicros: limit, - thresholdPercent: warningThresholdPercent, - }, true, nil + 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, + groupID: cost.effectiveGroupID.UUID, + spendLimitMicros: limit, + thresholdPercent: t.percent, + template: t.template, + }) + } } - return budgetThresholdCrossing{}, false, nil + return crossings, nil } -// notifyBudgetThresholdCrossing enqueues the warning notification for the user -// who crossed the threshold. +// notifyBudgetThresholdCrossing enqueues the notification for the user who +// crossed the threshold. func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing budgetThresholdCrossing) error { //nolint:gocritic // The interception context is scoped to AI Bridge; reading the group and enqueuing need system access. sysCtx := dbauthz.AsSystemRestricted(ctx) @@ -87,7 +111,7 @@ func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing bud "group_name": group.Name, } - if _, err := s.notifEnqueuer.EnqueueWithData(sysCtx, crossing.userID, notifications.TemplateAIBudgetWarningUser, + if _, err := s.notifEnqueuer.EnqueueWithData(sysCtx, crossing.userID, crossing.template, labels, nil, budgetNotificationsCreatedBy, crossing.groupID, ); err != nil { diff --git a/coderd/database/migrations/000548_ai_budget_notifications.down.sql b/coderd/database/migrations/000548_ai_budget_notifications.down.sql index 7d971ace7fe..33d74fdb990 100644 --- a/coderd/database/migrations/000548_ai_budget_notifications.down.sql +++ b/coderd/database/migrations/000548_ai_budget_notifications.down.sql @@ -1,2 +1,5 @@ DELETE FROM notification_templates -WHERE id = 'b5db9597-de2a-4dea-87e9-25cee6906b86'; +WHERE id IN ( + 'b5db9597-de2a-4dea-87e9-25cee6906b86', + 'cdcf2ecd-f003-4169-9800-abb2661ea522' +); diff --git a/coderd/database/migrations/000548_ai_budget_notifications.up.sql b/coderd/database/migrations/000548_ai_budget_notifications.up.sql index 86ba51230d6..35f6e69997f 100644 --- a/coderd/database/migrations/000548_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000548_ai_budget_notifications.up.sql @@ -13,7 +13,30 @@ VALUES ( 'b5db9597-de2a-4dea-87e9-25cee6906b86', 'AI Budget Warning Threshold Reached', E'You''re approaching your monthly AI budget limit', - E'You have used more than {{.Labels.threshold}}% of your monthly AI budget ({{.Labels.limit}}), set by **{{.Labels.group_name}}**.', + E'You have used more than {{.Labels.threshold}}% of your monthly AI budget ({{.Labels.limit}}). Effective group: **{{.Labels.group_name}}**.', + '[]'::jsonb, + 'AI Budget', + NULL, + 'system'::notification_template_kind, + true +); + +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) +VALUES ( + 'cdcf2ecd-f003-4169-9800-abb2661ea522', + 'AI Budget Limit Reached', + E'You''ve reached your monthly AI budget limit', + E'You have reached your monthly AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. Effective group: **{{.Labels.group_name}}**.', '[]'::jsonb, 'AI Budget', NULL, diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index e5d54ef2faf..b4eeca76326 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -72,5 +72,6 @@ var ( // AI budget-related events. var ( - TemplateAIBudgetWarningUser = uuid.MustParse("b5db9597-de2a-4dea-87e9-25cee6906b86") + TemplateAIBudgetWarningUser = uuid.MustParse("b5db9597-de2a-4dea-87e9-25cee6906b86") + TemplateAIBudgetLimitReachedUser = uuid.MustParse("cdcf2ecd-f003-4169-9800-abb2661ea522") ) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index b4d84dffded..2d1ffdaf83e 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1469,7 +1469,21 @@ func TestNotificationTemplates_Golden(t *testing.T) { UserEmail: "bobby@coder.com", UserUsername: "bobby", Labels: map[string]string{ - "threshold": "90", + "threshold": "85", + "limit": "$1000.00", + "group_name": "Engineering", + }, + Data: map[string]any{}, + }, + }, + { + name: "TemplateAIBudgetLimitReachedUser", + id: notifications.TemplateAIBudgetLimitReachedUser, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ "limit": "$1000.00", "group_name": "Engineering", }, diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden new file mode 100644 index 00000000000..2e85771d84b --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -0,0 +1,71 @@ +From: system@coder.com +To: bobby@coder.com +Subject: You've reached your 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, + +You have reached your monthly AI budget limit ($1000.00). Subsequent reques= +ts will be blocked. Effective group: Engineering. + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Codestin Search App + + +
+
+ 3D"Cod= +
+

+ You've reached your monthly AI budget limit +

+
+

Hi Bobby,

+

You have reached your monthly AI budget limit ($1000.00). Subseq= +uent requests will be blocked. Effective group: Engineering.

+
+
+ =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/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index 1bf4c72cb84..7fb9cdac3ea 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -12,8 +12,8 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, -You have used more than 90% of your monthly AI budget ($1000.00), set by En= -gineering. +You have used more than 85% of your monthly AI budget ($1000.00). Effective= + group: Engineering. --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -45,8 +45,8 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

-

You have used more than 90% of your monthly AI budget ($1000.00)= -, set by Engineering.

+

You have used more than 85% of your monthly AI budget ($1000.00)= +. Effective group: Engineering.

=20 diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden new file mode 100644 index 00000000000..f62225d8ba4 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden @@ -0,0 +1,24 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "AI Budget Limit Reached", + "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": { + "group_name": "Engineering", + "limit": "$1000.00" + }, + "data": {}, + "targets": null + }, + "title": "You've reached your monthly AI budget limit", + "title_markdown": "You've reached your monthly AI budget limit", + "body": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: Engineering.", + "body_markdown": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: **Engineering**." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden index 3140f23b821..8c2a97176f8 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -13,13 +13,13 @@ "labels": { "group_name": "Engineering", "limit": "$1000.00", - "threshold": "90" + "threshold": "85" }, "data": {}, "targets": null }, "title": "You're approaching your monthly AI budget limit", "title_markdown": "You're approaching your monthly AI budget limit", - "body": "You have used more than 90% of your monthly AI budget ($1000.00), set by Engineering.", - "body_markdown": "You have used more than 90% of your monthly AI budget ($1000.00), set by **Engineering**." + "body": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: Engineering.", + "body_markdown": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: **Engineering**." } \ No newline at end of file diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 967df0c0589..0390ffd24d6 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -285,6 +285,7 @@ function canSeeNotificationGroup( case "Task Events": case "Chat Events": case "Custom Events": + case "AI Budget": return true; default: return false; From 8dc829a468daa98aa826531953dab8a0373a58ba Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Tue, 21 Jul 2026 20:36:34 +0000 Subject: [PATCH 03/25] chore: fix migration numbers --- ...fications.down.sql => 000551_ai_budget_notifications.down.sql} | 0 ...notifications.up.sql => 000551_ai_budget_notifications.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000548_ai_budget_notifications.down.sql => 000551_ai_budget_notifications.down.sql} (100%) rename coderd/database/migrations/{000548_ai_budget_notifications.up.sql => 000551_ai_budget_notifications.up.sql} (100%) diff --git a/coderd/database/migrations/000548_ai_budget_notifications.down.sql b/coderd/database/migrations/000551_ai_budget_notifications.down.sql similarity index 100% rename from coderd/database/migrations/000548_ai_budget_notifications.down.sql rename to coderd/database/migrations/000551_ai_budget_notifications.down.sql diff --git a/coderd/database/migrations/000548_ai_budget_notifications.up.sql b/coderd/database/migrations/000551_ai_budget_notifications.up.sql similarity index 100% rename from coderd/database/migrations/000548_ai_budget_notifications.up.sql rename to coderd/database/migrations/000551_ai_budget_notifications.up.sql From c40737a8dcf82977323529d02f99c77560530898 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 14:58:53 +0000 Subject: [PATCH 04/25] chore: shorten AI budget warning notification name to AI Budget Warning --- .../database/migrations/000551_ai_budget_notifications.up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/migrations/000551_ai_budget_notifications.up.sql b/coderd/database/migrations/000551_ai_budget_notifications.up.sql index 35f6e69997f..a6dfe4d25c0 100644 --- a/coderd/database/migrations/000551_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000551_ai_budget_notifications.up.sql @@ -11,7 +11,7 @@ INSERT INTO notification_templates ( ) VALUES ( 'b5db9597-de2a-4dea-87e9-25cee6906b86', - 'AI Budget Warning Threshold Reached', + 'AI Budget Warning', E'You''re approaching your monthly AI budget limit', E'You have used more than {{.Labels.threshold}}% of your monthly AI budget ({{.Labels.limit}}). Effective group: **{{.Labels.group_name}}**.', '[]'::jsonb, From 05bc337dd665b1939e783a06b6e361f347e6d8d3 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 15:15:26 +0000 Subject: [PATCH 05/25] chore: regenerate webhook golden for AI budget warning rename --- .../webhook/TemplateAIBudgetWarningUser.json.golden | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden index 8c2a97176f8..2622e521d43 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -3,7 +3,7 @@ "msg_id": "00000000-0000-0000-0000-000000000000", "payload": { "_version": "1.2", - "notification_name": "AI Budget Warning Threshold Reached", + "notification_name": "AI Budget Warning", "notification_template_id": "00000000-0000-0000-0000-000000000000", "user_id": "00000000-0000-0000-0000-000000000000", "user_email": "bobby@coder.com", From 6aa58d130ad468650b238da6b3111f4bd91e03ea Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 17:03:31 +0000 Subject: [PATCH 06/25] fix: derive AI budget period from interception createdAt --- coderd/aibridgedserver/aibridgedserver.go | 2 +- coderd/aibridgedserver/notifications.go | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 9f0d3f66425..a86b33fc3e3 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -421,7 +421,7 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB // Threshold detection is best-effort: a failed read must not roll back // the committed spend, so the error is logged rather than propagated. var detectErr error - crossings, detectErr = s.detectBudgetThresholdCrossings(ctx, tx, intc, cost) + crossings, detectErr = s.detectBudgetThresholdCrossings(ctx, tx, intc, cost, createdAt) if detectErr != nil { // log and continue so a failed read does not roll back the committed spend. s.logger.Warn(ctx, "failed to detect AI budget threshold crossing", diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index b46592be224..14c7c54a252 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -54,12 +55,18 @@ type budgetThresholdCrossing struct { // 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). -func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database.Store, intc database.AIBridgeInterception, cost tokenUsageCost) ([]budgetThresholdCrossing, error) { +// +// The period is derived from createdAt, the same timestamp the spend row is +// bucketed by. Using it (rather than the current wall clock) keeps the summed +// window and the incremented row in the same period, so an interception whose +// createdAt and processing time span across a period boundary is evaluated +// against the period it was recorded in. +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(s.clock.Now(), s.budgetPeriod) + period, err := budget.CurrentPeriod(createdAt, s.budgetPeriod) if err != nil { return nil, xerrors.Errorf("compute AI budget period: %w", err) } From 98ed7509b8f94d3dcbc58bc57f68697a9a305ff3 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 18:12:08 +0000 Subject: [PATCH 07/25] test: cover AI budget notification across period boundary --- .../aibridgedserver/aibridgedserver_test.go | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 1e808663e81..eaa1ba63879 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2453,6 +2453,87 @@ func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { } } +// TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary verifies that an +// interception created in one budget period but processed after the period has +// rolled over is still evaluated against the period it belongs to, so a genuine +// threshold crossing is detected rather than lost across the boundary. +func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { + t.Parallel() + + const ( + spendLimitMicros int64 = 1_000_000 // $1 limit + warnAtMicros int64 = 850_000 // 85% of the limit + inputPriceMicros int64 = 1_000_000 // $1 per million tokens + ) + + // The interception was created in the final second of January but is + // processed just after the rollover into February. The spend is bucketed + // into January, so detection must sum against January's period. + createdAt := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC) + processedAt := time.Date(2026, 2, 1, 0, 0, 1, 0, time.UTC) + januaryStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + enq := ¬ificationstest.FakeEnqueuer{} + + intc := newTestInterception(uuid.New()) + groupID := uuid.New() + group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimitMicros} + price := &database.AIModelPrice{InputPrice: sql.NullInt64{Int64: inputPriceMicros, Valid: true}} + + expectTokenUsageCostLookups(db, intc, nil, group, 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) + + // The spend query must run against the period the interception belongs to + // (January), not the period it was processed in (February). + var gotPeriodStart time.Time + db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, p database.GetUserAISpendSinceParams) (database.GetUserAISpendSinceRow, error) { + gotPeriodStart = p.PeriodStart + return database.GetUserAISpendSinceRow{SpendMicros: warnAtMicros}, nil + }) + db.EXPECT().GetGroupByID(gomock.Any(), groupID). + Return(database.Group{ID: groupID, Name: "Engineering"}, nil) + + clock := quartz.NewMock(t) + clock.Set(processedAt) + + 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: clock, + }) + require.NoError(t, err) + + _, err = srv.RecordTokenUsage(ctx, &proto.RecordTokenUsageRequest{ + InterceptionId: intc.ID.String(), + MsgId: "msg_boundary", + InputTokens: 1000, + CreatedAt: timestamppb.New(createdAt), + }) + require.NoError(t, err) + + require.Equal(t, januaryStart, gotPeriodStart, + "spend must be summed against the period the interception belongs to, not the processing period") + + sent := enq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIBudgetWarningUser)) + require.Len(t, sent, 1, "expected the crossing to be detected against the interception's period") +} + // newTestInterception returns an interception with a fixed initiator, provider, // and model for cost-attribution test setup. func newTestInterception(id uuid.UUID) database.AIBridgeInterception { From aadb31f0d617fb6be792cc37b4dd50ce63cecce0 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 15:32:01 -0400 Subject: [PATCH 08/25] refactor: scope AI budget notifications to least privilege (#27433) --- coderd/aibridgedserver/notifications.go | 8 +++----- coderd/database/dbauthz/dbauthz.go | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index 14c7c54a252..8e8c6e540c7 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -104,10 +104,7 @@ func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database // notifyBudgetThresholdCrossing enqueues the notification for the user who // crossed the threshold. func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing budgetThresholdCrossing) error { - //nolint:gocritic // The interception context is scoped to AI Bridge; reading the group and enqueuing need system access. - sysCtx := dbauthz.AsSystemRestricted(ctx) - - group, err := s.store.GetGroupByID(sysCtx, crossing.groupID) + group, err := s.store.GetGroupByID(ctx, crossing.groupID) if err != nil { return xerrors.Errorf("look up group %q: %w", crossing.groupID, err) } @@ -118,7 +115,8 @@ func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing bud "group_name": group.Name, } - if _, err := s.notifEnqueuer.EnqueueWithData(sysCtx, crossing.userID, crossing.template, + //nolint:gocritic // Enqueuing notifications requires the notifier actor. + if _, err := s.notifEnqueuer.EnqueueWithData(dbauthz.AsNotifier(ctx), crossing.userID, crossing.template, labels, nil, budgetNotificationsCreatedBy, crossing.groupID, ); err != nil { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3161f3c3e9d..ca771572672 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -696,6 +696,7 @@ var ( rbac.ResourceAiModelPrice.Type: {policy.ActionRead, policy.ActionUpdate}, // Read: per-interception cost lookup. Update: startup price seeder. rbac.ResourceAiSeat.Type: {policy.ActionCreate}, // Required for UpsertAISeatState. rbac.ResourceAIProvider.Type: {policy.ActionRead}, // Required to load the provider snapshot (and per-provider keys) at startup. + rbac.ResourceGroup.Type: {policy.ActionRead}, // Required to read the effective group. }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, From 61731581f6db0f6caabced87180048c076d4af33 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 16:17:23 -0400 Subject: [PATCH 09/25] feat: template AI budget period in notifications (#27434) --- coderd/aibridgedserver/notifications.go | 1 + .../000551_ai_budget_notifications.up.sql | 8 ++++---- coderd/notifications/notifications_test.go | 2 ++ .../TemplateAIBudgetLimitReachedUser.json.golden | 3 ++- .../TemplateAIBudgetWarningUser.json.golden | 1 + codersdk/deployment.go | 16 ++++++++++++++++ codersdk/deployment_test.go | 13 +++++++++++++ 7 files changed, 39 insertions(+), 5 deletions(-) diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index 8e8c6e540c7..35270195e64 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -113,6 +113,7 @@ func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing bud "threshold": strconv.Itoa(crossing.thresholdPercent), "limit": formatSpendLimit(crossing.spendLimitMicros), "group_name": group.Name, + "period": s.budgetPeriod.Adjective(), } //nolint:gocritic // Enqueuing notifications requires the notifier actor. diff --git a/coderd/database/migrations/000551_ai_budget_notifications.up.sql b/coderd/database/migrations/000551_ai_budget_notifications.up.sql index a6dfe4d25c0..400d324bf54 100644 --- a/coderd/database/migrations/000551_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000551_ai_budget_notifications.up.sql @@ -12,8 +12,8 @@ INSERT INTO notification_templates ( VALUES ( 'b5db9597-de2a-4dea-87e9-25cee6906b86', 'AI Budget Warning', - E'You''re approaching your monthly AI budget limit', - E'You have used more than {{.Labels.threshold}}% of your monthly AI budget ({{.Labels.limit}}). Effective group: **{{.Labels.group_name}}**.', + E'You''re approaching your {{.Labels.period}} AI budget limit', + E'You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). Effective group: **{{.Labels.group_name}}**.', '[]'::jsonb, 'AI Budget', NULL, @@ -35,8 +35,8 @@ INSERT INTO notification_templates ( VALUES ( 'cdcf2ecd-f003-4169-9800-abb2661ea522', 'AI Budget Limit Reached', - E'You''ve reached your monthly AI budget limit', - E'You have reached your monthly AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. Effective group: **{{.Labels.group_name}}**.', + E'You''ve reached your {{.Labels.period}} AI budget limit', + E'You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. Effective group: **{{.Labels.group_name}}**.', '[]'::jsonb, 'AI Budget', NULL, diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 2d1ffdaf83e..104425aebf5 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1472,6 +1472,7 @@ func TestNotificationTemplates_Golden(t *testing.T) { "threshold": "85", "limit": "$1000.00", "group_name": "Engineering", + "period": "monthly", }, Data: map[string]any{}, }, @@ -1486,6 +1487,7 @@ func TestNotificationTemplates_Golden(t *testing.T) { Labels: map[string]string{ "limit": "$1000.00", "group_name": "Engineering", + "period": "monthly", }, Data: map[string]any{}, }, diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden index f62225d8ba4..1e7a21f3cb6 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden @@ -12,7 +12,8 @@ "actions": [], "labels": { "group_name": "Engineering", - "limit": "$1000.00" + "limit": "$1000.00", + "period": "monthly" }, "data": {}, "targets": null diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden index 2622e521d43..cb5bdb36e82 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -13,6 +13,7 @@ "labels": { "group_name": "Engineering", "limit": "$1000.00", + "period": "monthly", "threshold": "85" }, "data": {}, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2c4a0bffa9d..4a749c21fc5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -612,6 +612,22 @@ var AIBudgetPeriods = []string{ string(AIBudgetPeriodMonth), } +// Adjective renders the period as the adjective used in user-facing text (e.g. "monthly"). +func (p AIBudgetPeriod) Adjective() string { + switch p { + case "day": + return "daily" + case "week": + return "weekly" + case AIBudgetPeriodMonth: + return "monthly" + case "year": + return "yearly" + default: + return string(p) + } +} + // NewAIBudgetPeriodFromString converts s to an AIBudgetPeriod, falling back to // AIBudgetPeriodMonth when s is empty or not a recognized period. func NewAIBudgetPeriodFromString(s string) AIBudgetPeriod { diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index a70fef938a6..f8eb4050178 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -149,6 +149,19 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { } } +func TestAIBudgetPeriodAdjective(t *testing.T) { + t.Parallel() + + // Every selectable period must have a real adjective. + for _, p := range codersdk.AIBudgetPeriods { + period := codersdk.AIBudgetPeriod(p) + require.NotEqual(t, p, period.Adjective(), + "add an adjective for AI budget period %q in AIBudgetPeriod.Adjective", p) + } + + require.Equal(t, "monthly", codersdk.AIBudgetPeriodMonth.Adjective()) +} + func TestParseSSHConfigOption(t *testing.T) { t.Parallel() From 6fd2bb7e27757850e43052cd530e3f0fdb7a18ae Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 22 Jul 2026 20:12:22 -0400 Subject: [PATCH 10/25] fix: only name the group when it set the AI budget limit (#27436) --- .../aibridgedserver/aibridgedserver_test.go | 71 +++++++++++++++++++ coderd/aibridgedserver/cost.go | 3 + coderd/aibridgedserver/notifications.go | 12 ++-- .../000551_ai_budget_notifications.up.sql | 18 ++++- coderd/notifications/notifications_test.go | 16 +++-- ...mplateAIBudgetLimitReachedUser.html.golden | 9 ++- .../TemplateAIBudgetWarningUser.html.golden | 9 ++- ...mplateAIBudgetLimitReachedUser.json.golden | 5 +- .../TemplateAIBudgetWarningUser.json.golden | 5 +- 9 files changed, 125 insertions(+), 23 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index eaa1ba63879..96d6e9b9d18 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2448,6 +2448,7 @@ func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { require.Equal(t, intc.InitiatorID, sent[0].UserID) require.Equal(t, "$1.00", sent[0].Labels["limit"]) require.Equal(t, "Engineering", sent[0].Labels["group_name"]) + require.Equal(t, string(codersdk.AIBudgetLimitSourceGroup), sent[0].Labels["limit_source"]) } }) } @@ -2534,6 +2535,76 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { require.Len(t, sent, 1, "expected the crossing to be detected against the interception's period") } +// TestRecordTokenUsageBudgetNotificationUserOverride verifies that when the +// limit comes from a per-user override, the notification reflects that. +func TestRecordTokenUsageBudgetNotificationUserOverride(t *testing.T) { + t.Parallel() + + const ( + spendLimitMicros int64 = 1_000_000 // $1 limit + warnAtMicros int64 = 850_000 // 85% of the limit + inputPriceMicros int64 = 1_000_000 // $1 per million tokens + ) + now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + enq := ¬ificationstest.FakeEnqueuer{} + + intc := newTestInterception(uuid.New()) + groupID := uuid.New() + override := &database.UserAIBudgetOverride{ + UserID: intc.InitiatorID, + GroupID: groupID, + SpendLimitMicros: spendLimitMicros, + } + price := &database.AIModelPrice{InputPrice: sql.NullInt64{Int64: inputPriceMicros, Valid: true}} + + expectTokenUsageCostLookups(db, intc, override, 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: warnAtMicros}, nil) + db.EXPECT().GetGroupByID(gomock.Any(), groupID). + Return(database.Group{ID: groupID, Name: "Engineering"}, 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_override", + InputTokens: 1000, + CreatedAt: timestamppb.New(now), + }) + require.NoError(t, err) + + sent := enq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIBudgetWarningUser)) + require.Len(t, sent, 1, "expected one budget warning notification") + require.Equal(t, intc.InitiatorID, sent[0].UserID) + require.Equal(t, "$1.00", sent[0].Labels["limit"]) + require.Equal(t, "Engineering", sent[0].Labels["group_name"], + "attribution group must be surfaced even for an override") + require.Equal(t, string(codersdk.AIBudgetLimitSourceUserOverride), sent[0].Labels["limit_source"], + "limit source must be recorded so the template does not attribute the limit to the group") +} + // 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 3952da163f7..1ff5c30bc1d 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 @@ -49,6 +51,7 @@ func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBrid if ok { result.effectiveGroupID = uuid.NullUUID{UUID: effectiveBudget.GroupID, Valid: true} result.spendLimitMicros = sql.NullInt64{Int64: effectiveBudget.SpendLimitMicros, Valid: true} + result.limitSource = effectiveBudget.Source } // Snapshot the price for this (provider, model) and compute cost. diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index 35270195e64..9f63fecb2b0 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -13,6 +13,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 @@ -49,6 +50,7 @@ type budgetThresholdCrossing struct { spendLimitMicros int64 thresholdPercent int template uuid.UUID + limitSource codersdk.AIBudgetLimitSource } // detectBudgetThresholdCrossings checks whether this interception's cost pushed @@ -95,6 +97,7 @@ func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database spendLimitMicros: limit, thresholdPercent: t.percent, template: t.template, + limitSource: cost.limitSource, }) } } @@ -110,10 +113,11 @@ func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing bud } labels := map[string]string{ - "threshold": strconv.Itoa(crossing.thresholdPercent), - "limit": formatSpendLimit(crossing.spendLimitMicros), - "group_name": group.Name, - "period": s.budgetPeriod.Adjective(), + "threshold": strconv.Itoa(crossing.thresholdPercent), + "limit": formatSpendLimit(crossing.spendLimitMicros), + "period": s.budgetPeriod.Adjective(), + "limit_source": string(crossing.limitSource), + "group_name": group.Name, } //nolint:gocritic // Enqueuing notifications requires the notifier actor. diff --git a/coderd/database/migrations/000551_ai_budget_notifications.up.sql b/coderd/database/migrations/000551_ai_budget_notifications.up.sql index 400d324bf54..605f54e6b76 100644 --- a/coderd/database/migrations/000551_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000551_ai_budget_notifications.up.sql @@ -13,7 +13,14 @@ VALUES ( 'b5db9597-de2a-4dea-87e9-25cee6906b86', 'AI Budget Warning', E'You''re approaching your {{.Labels.period}} AI budget limit', - E'You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). Effective group: **{{.Labels.group_name}}**.', + $$You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). +{{- if eq .Labels.limit_source "group"}} + +This limit is set by your group **{{.Labels.group_name}}**. +{{- else if eq .Labels.limit_source "user_override"}} + +This limit is set specifically for your account. +{{- end}}$$, '[]'::jsonb, 'AI Budget', NULL, @@ -36,7 +43,14 @@ VALUES ( 'cdcf2ecd-f003-4169-9800-abb2661ea522', 'AI Budget Limit Reached', E'You''ve reached your {{.Labels.period}} AI budget limit', - E'You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. Effective group: **{{.Labels.group_name}}**.', + $$You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. +{{- if eq .Labels.limit_source "group"}} + +This limit is set by your group **{{.Labels.group_name}}**. +{{- else if eq .Labels.limit_source "user_override"}} + +This limit is set specifically for your account. +{{- end}}$$, '[]'::jsonb, 'AI Budget', NULL, diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 104425aebf5..e17d36238b5 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1469,10 +1469,11 @@ func TestNotificationTemplates_Golden(t *testing.T) { UserEmail: "bobby@coder.com", UserUsername: "bobby", Labels: map[string]string{ - "threshold": "85", - "limit": "$1000.00", - "group_name": "Engineering", - "period": "monthly", + "threshold": "85", + "limit": "$1000.00", + "period": "monthly", + "limit_source": "group", + "group_name": "Engineering", }, Data: map[string]any{}, }, @@ -1485,9 +1486,10 @@ func TestNotificationTemplates_Golden(t *testing.T) { UserEmail: "bobby@coder.com", UserUsername: "bobby", Labels: map[string]string{ - "limit": "$1000.00", - "group_name": "Engineering", - "period": "monthly", + "limit": "$1000.00", + "period": "monthly", + "limit_source": "user_override", + "group_name": "Engineering", }, Data: map[string]any{}, }, diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden index 2e85771d84b..07e5fc7195c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -13,7 +13,9 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, You have reached your monthly AI budget limit ($1000.00). Subsequent reques= -ts will be blocked. Effective group: Engineering. +ts will be blocked. + +This limit is set specifically for your account. --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -46,8 +48,9 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

You have reached your monthly AI budget limit ($1000.00). Subseq= -uent requests will be blocked. Effective group: Engineering.

+uent requests will be blocked.

+ +

This limit is set specifically for your account.

=20 diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index 7fb9cdac3ea..c5c572756d0 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -12,8 +12,9 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, -You have used more than 85% of your monthly AI budget ($1000.00). Effective= - group: Engineering. +You have used more than 85% of your monthly AI budget ($1000.00). + +This limit is set by your group Engineering. --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -46,7 +47,9 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

You have used more than 85% of your monthly AI budget ($1000.00)= -. Effective group: Engineering.

+.

+ +

This limit is set by your group Engineering.

=20 diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden index 1e7a21f3cb6..45ce65ef2be 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden @@ -13,6 +13,7 @@ "labels": { "group_name": "Engineering", "limit": "$1000.00", + "limit_source": "user_override", "period": "monthly" }, "data": {}, @@ -20,6 +21,6 @@ }, "title": "You've reached your monthly AI budget limit", "title_markdown": "You've reached your monthly AI budget limit", - "body": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: Engineering.", - "body_markdown": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: **Engineering**." + "body": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nThis limit is set specifically for your account.", + "body_markdown": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nThis limit is set specifically for your account." } \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden index cb5bdb36e82..2ec96fe7955 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -13,6 +13,7 @@ "labels": { "group_name": "Engineering", "limit": "$1000.00", + "limit_source": "group", "period": "monthly", "threshold": "85" }, @@ -21,6 +22,6 @@ }, "title": "You're approaching your monthly AI budget limit", "title_markdown": "You're approaching your monthly AI budget limit", - "body": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: Engineering.", - "body_markdown": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: **Engineering**." + "body": "You have used more than 85% of your monthly AI budget ($1000.00).\n\nThis limit is set by your group Engineering.", + "body_markdown": "You have used more than 85% of your monthly AI budget ($1000.00).\n\nThis limit is set by your group **Engineering**." } \ No newline at end of file From 4d05f29826e000a5cb4ae6fadcb5e3083a1e765e Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 00:29:12 +0000 Subject: [PATCH 11/25] test: add threshold label to AI budget limit golden --- coderd/notifications/notifications_test.go | 1 + .../webhook/TemplateAIBudgetLimitReachedUser.json.golden | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index e17d36238b5..c678c2417fe 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1486,6 +1486,7 @@ func TestNotificationTemplates_Golden(t *testing.T) { UserEmail: "bobby@coder.com", UserUsername: "bobby", Labels: map[string]string{ + "threshold": "100", "limit": "$1000.00", "period": "monthly", "limit_source": "user_override", diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden index 45ce65ef2be..95b7a77ffbb 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden @@ -14,7 +14,8 @@ "group_name": "Engineering", "limit": "$1000.00", "limit_source": "user_override", - "period": "monthly" + "period": "monthly", + "threshold": "100" }, "data": {}, "targets": null From 3720ea0fa529f5ac83b1a84dbc93c6b2e49fd377 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 13:05:51 +0000 Subject: [PATCH 12/25] chore: rename AI budget notification group to AI Budget Events --- .../database/migrations/000551_ai_budget_notifications.up.sql | 4 ++-- .../UserSettingsPage/NotificationsPage/NotificationsPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/database/migrations/000551_ai_budget_notifications.up.sql b/coderd/database/migrations/000551_ai_budget_notifications.up.sql index 605f54e6b76..f8fbcd7bca5 100644 --- a/coderd/database/migrations/000551_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000551_ai_budget_notifications.up.sql @@ -22,7 +22,7 @@ This limit is set by your group **{{.Labels.group_name}}**. This limit is set specifically for your account. {{- end}}$$, '[]'::jsonb, - 'AI Budget', + 'AI Budget Events', NULL, 'system'::notification_template_kind, true @@ -52,7 +52,7 @@ This limit is set by your group **{{.Labels.group_name}}**. This limit is set specifically for your account. {{- end}}$$, '[]'::jsonb, - 'AI Budget', + 'AI Budget Events', NULL, 'system'::notification_template_kind, true diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 0390ffd24d6..4f91e75296f 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -285,7 +285,7 @@ function canSeeNotificationGroup( case "Task Events": case "Chat Events": case "Custom Events": - case "AI Budget": + case "AI Budget Events": return true; default: return false; From da5a88daed7cc41d4eed5149cffab3eb4f0802e4 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 13:09:15 +0000 Subject: [PATCH 13/25] fix: minor fixes --- coderd/aibridgedserver/aibridgedserver.go | 1 - coderd/aibridgedserver/aibridgedserver_test.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index a86b33fc3e3..4e395a5c644 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -423,7 +423,6 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB var detectErr error crossings, detectErr = s.detectBudgetThresholdCrossings(ctx, tx, intc, cost, createdAt) if detectErr != nil { - // log and continue so a failed read does not roll back the committed spend. s.logger.Warn(ctx, "failed to detect AI budget threshold crossing", slog.F("interception_id", intc.ID), slog.F("initiator_id", intc.InitiatorID), diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 96d6e9b9d18..b0763984dff 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2324,7 +2324,7 @@ func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { _, err = srv.RecordTokenUsage(ctx, &proto.RecordTokenUsageRequest{ InterceptionId: intc.ID.String(), MsgId: "msg_123", - InputTokens: 1000, + InputTokens: inputTokens, CreatedAt: timestamppb.New(now), }) require.NoError(t, err) From 2515e0af354b5eda6f1853dbfdf98d9cc215a5f5 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 17:18:53 +0000 Subject: [PATCH 14/25] fix: CR's fixes --- 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 4e395a5c644..63fe27db558 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -423,7 +423,7 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB var detectErr error crossings, detectErr = s.detectBudgetThresholdCrossings(ctx, tx, intc, cost, createdAt) if detectErr != nil { - s.logger.Warn(ctx, "failed to detect AI budget threshold crossing", + s.logger.Error(ctx, "failed to detect AI budget threshold crossing", slog.F("interception_id", intc.ID), slog.F("initiator_id", intc.InitiatorID), slog.Error(detectErr)) From 9a04f6b752d5ca4f7ca6a08f6f6fec6ed73e7bde Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 18:08:27 +0000 Subject: [PATCH 15/25] chore: rename AI notification group to AI Cost Control Events --- .../database/migrations/000551_ai_budget_notifications.up.sql | 4 ++-- coderd/notifications/events.go | 2 +- .../UserSettingsPage/NotificationsPage/NotificationsPage.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/database/migrations/000551_ai_budget_notifications.up.sql b/coderd/database/migrations/000551_ai_budget_notifications.up.sql index f8fbcd7bca5..ebe7cbb097c 100644 --- a/coderd/database/migrations/000551_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000551_ai_budget_notifications.up.sql @@ -22,7 +22,7 @@ This limit is set by your group **{{.Labels.group_name}}**. This limit is set specifically for your account. {{- end}}$$, '[]'::jsonb, - 'AI Budget Events', + 'AI Cost Control Events', NULL, 'system'::notification_template_kind, true @@ -52,7 +52,7 @@ This limit is set by your group **{{.Labels.group_name}}**. This limit is set specifically for your account. {{- end}}$$, '[]'::jsonb, - 'AI Budget Events', + 'AI Cost Control Events', NULL, 'system'::notification_template_kind, true diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index b4eeca76326..c2885cc9866 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -70,7 +70,7 @@ var ( TemplateChatShared = uuid.MustParse("b789bd75-d7c6-4cab-9757-1147ab184903") ) -// AI budget-related events. +// AI cost control related events. var ( TemplateAIBudgetWarningUser = uuid.MustParse("b5db9597-de2a-4dea-87e9-25cee6906b86") TemplateAIBudgetLimitReachedUser = uuid.MustParse("cdcf2ecd-f003-4169-9800-abb2661ea522") diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 4f91e75296f..8910e875a0b 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -285,7 +285,7 @@ function canSeeNotificationGroup( case "Task Events": case "Chat Events": case "Custom Events": - case "AI Budget Events": + case "AI Cost Control Events": return true; default: return false; From d3f659d229954d1be29992019d63965d60bd103c Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 18:26:10 +0000 Subject: [PATCH 16/25] chore: use effective-group naming in AI budget notifications --- coderd/aibridgedserver/aibridgedserver.go | 2 +- .../aibridgedserver/aibridgedserver_test.go | 6 +++--- coderd/aibridgedserver/notifications.go | 20 +++++++++---------- .../000551_ai_budget_notifications.up.sql | 4 ++-- coderd/notifications/notifications_test.go | 20 +++++++++---------- ...mplateAIBudgetLimitReachedUser.json.golden | 2 +- .../TemplateAIBudgetWarningUser.json.golden | 2 +- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 63fe27db558..3a97481d704 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -438,7 +438,7 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB if err := s.notifyBudgetThresholdCrossing(ctx, crossing); err != nil { s.logger.Warn(ctx, "failed to send AI budget notification", slog.F("user_id", crossing.userID), - slog.F("group_id", crossing.groupID), + slog.F("group_id", crossing.effectiveGroupID), slog.F("threshold_percent", crossing.thresholdPercent), slog.Error(err)) } diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index b0763984dff..4fb69a74366 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2338,7 +2338,7 @@ func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { require.Equal(t, intc.InitiatorID, sent[0].UserID) require.Equal(t, tc.wantThreshold, sent[0].Labels["threshold"]) require.Equal(t, tc.wantLimit, sent[0].Labels["limit"]) - require.Equal(t, "Engineering", sent[0].Labels["group_name"]) + require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"]) }) } } @@ -2447,7 +2447,7 @@ func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { require.Len(t, sent, 1, "expected one notification for template %s", tmpl) require.Equal(t, intc.InitiatorID, sent[0].UserID) require.Equal(t, "$1.00", sent[0].Labels["limit"]) - require.Equal(t, "Engineering", sent[0].Labels["group_name"]) + require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"]) require.Equal(t, string(codersdk.AIBudgetLimitSourceGroup), sent[0].Labels["limit_source"]) } }) @@ -2599,7 +2599,7 @@ func TestRecordTokenUsageBudgetNotificationUserOverride(t *testing.T) { require.Len(t, sent, 1, "expected one budget warning notification") require.Equal(t, intc.InitiatorID, sent[0].UserID) require.Equal(t, "$1.00", sent[0].Labels["limit"]) - require.Equal(t, "Engineering", sent[0].Labels["group_name"], + require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"], "attribution group must be surfaced even for an override") require.Equal(t, string(codersdk.AIBudgetLimitSourceUserOverride), sent[0].Labels["limit_source"], "limit source must be recorded so the template does not attribute the limit to the group") diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index 9f63fecb2b0..379fce7a981 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -46,7 +46,7 @@ var budgetThresholds = []budgetThreshold{ // duplicate is dropped. type budgetThresholdCrossing struct { userID uuid.UUID - groupID uuid.UUID + effectiveGroupID uuid.UUID spendLimitMicros int64 thresholdPercent int template uuid.UUID @@ -93,7 +93,7 @@ func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database if oldSpend < at && newSpend >= at { crossings = append(crossings, budgetThresholdCrossing{ userID: intc.InitiatorID, - groupID: cost.effectiveGroupID.UUID, + effectiveGroupID: cost.effectiveGroupID.UUID, spendLimitMicros: limit, thresholdPercent: t.percent, template: t.template, @@ -107,23 +107,23 @@ func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database // 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.groupID) + group, err := s.store.GetGroupByID(ctx, crossing.effectiveGroupID) if err != nil { - return xerrors.Errorf("look up group %q: %w", crossing.groupID, err) + return xerrors.Errorf("look up group %q: %w", crossing.effectiveGroupID, err) } labels := map[string]string{ - "threshold": strconv.Itoa(crossing.thresholdPercent), - "limit": formatSpendLimit(crossing.spendLimitMicros), - "period": s.budgetPeriod.Adjective(), - "limit_source": string(crossing.limitSource), - "group_name": group.Name, + "threshold": strconv.Itoa(crossing.thresholdPercent), + "limit": formatSpendLimit(crossing.spendLimitMicros), + "period": s.budgetPeriod.Adjective(), + "limit_source": string(crossing.limitSource), + "effective_group_name": group.Name, } //nolint:gocritic // Enqueuing notifications requires the notifier actor. if _, err := s.notifEnqueuer.EnqueueWithData(dbauthz.AsNotifier(ctx), crossing.userID, crossing.template, labels, nil, budgetNotificationsCreatedBy, - crossing.groupID, + crossing.effectiveGroupID, ); err != nil { return xerrors.Errorf("enqueue notification: %w", err) } diff --git a/coderd/database/migrations/000551_ai_budget_notifications.up.sql b/coderd/database/migrations/000551_ai_budget_notifications.up.sql index ebe7cbb097c..85e51dac451 100644 --- a/coderd/database/migrations/000551_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000551_ai_budget_notifications.up.sql @@ -16,7 +16,7 @@ VALUES ( $$You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). {{- if eq .Labels.limit_source "group"}} -This limit is set by your group **{{.Labels.group_name}}**. +This limit is set by your group **{{.Labels.effective_group_name}}**. {{- else if eq .Labels.limit_source "user_override"}} This limit is set specifically for your account. @@ -46,7 +46,7 @@ VALUES ( $$You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. {{- if eq .Labels.limit_source "group"}} -This limit is set by your group **{{.Labels.group_name}}**. +This limit is set by your group **{{.Labels.effective_group_name}}**. {{- else if eq .Labels.limit_source "user_override"}} This limit is set specifically for your account. diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index c678c2417fe..6adecce4bc7 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1469,11 +1469,11 @@ func TestNotificationTemplates_Golden(t *testing.T) { UserEmail: "bobby@coder.com", UserUsername: "bobby", Labels: map[string]string{ - "threshold": "85", - "limit": "$1000.00", - "period": "monthly", - "limit_source": "group", - "group_name": "Engineering", + "threshold": "85", + "limit": "$1000.00", + "period": "monthly", + "limit_source": "group", + "effective_group_name": "Engineering", }, Data: map[string]any{}, }, @@ -1486,11 +1486,11 @@ func TestNotificationTemplates_Golden(t *testing.T) { UserEmail: "bobby@coder.com", UserUsername: "bobby", Labels: map[string]string{ - "threshold": "100", - "limit": "$1000.00", - "period": "monthly", - "limit_source": "user_override", - "group_name": "Engineering", + "threshold": "100", + "limit": "$1000.00", + "period": "monthly", + "limit_source": "user_override", + "effective_group_name": "Engineering", }, Data: map[string]any{}, }, diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden index 95b7a77ffbb..3ea508b9f96 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden @@ -11,7 +11,7 @@ "user_username": "bobby", "actions": [], "labels": { - "group_name": "Engineering", + "effective_group_name": "Engineering", "limit": "$1000.00", "limit_source": "user_override", "period": "monthly", diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden index 2ec96fe7955..0b2862f5fa5 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -11,7 +11,7 @@ "user_username": "bobby", "actions": [], "labels": { - "group_name": "Engineering", + "effective_group_name": "Engineering", "limit": "$1000.00", "limit_source": "group", "period": "monthly", From 733ce14bdce6c0d8839138b42b68a9ca986b45cd Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 19:09:23 +0000 Subject: [PATCH 17/25] chore: rename template field and clarify period comment --- coderd/aibridgedserver/notifications.go | 43 ++++++++++++------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index 379fce7a981..bdb37641e10 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -29,15 +29,15 @@ const budgetNotificationsCreatedBy = "aigateway" // budgetThreshold pairs a percentage of the spend limit with the notification // template sent when a user's spend crosses it. type budgetThreshold struct { - percent int - template uuid.UUID + percent int + notificationTemplate 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, template: notifications.TemplateAIBudgetWarningUser}, - {percent: limitThresholdPercent, template: notifications.TemplateAIBudgetLimitReachedUser}, + {percent: warningThresholdPercent, notificationTemplate: notifications.TemplateAIBudgetWarningUser}, + {percent: limitThresholdPercent, notificationTemplate: notifications.TemplateAIBudgetLimitReachedUser}, } // budgetThresholdCrossing describes a user crossing a budget threshold on a @@ -45,12 +45,12 @@ 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 - template uuid.UUID - limitSource codersdk.AIBudgetLimitSource + userID uuid.UUID + effectiveGroupID uuid.UUID + spendLimitMicros int64 + thresholdPercent int + notificationTemplate uuid.UUID + limitSource codersdk.AIBudgetLimitSource } // detectBudgetThresholdCrossings checks whether this interception's cost pushed @@ -58,11 +58,10 @@ type budgetThresholdCrossing struct { // crossed. A single interception can cross several at once (e.g. straight past // both the warning and limit thresholds). // -// The period is derived from createdAt, the same timestamp the spend row is -// bucketed by. Using it (rather than the current wall clock) keeps the summed -// window and the incremented row in the same period, so an interception whose -// createdAt and processing time span across a period boundary is evaluated -// against the period it was recorded in. +// 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 @@ -92,12 +91,12 @@ 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, - template: t.template, - limitSource: cost.limitSource, + userID: intc.InitiatorID, + effectiveGroupID: cost.effectiveGroupID.UUID, + spendLimitMicros: limit, + thresholdPercent: t.percent, + notificationTemplate: t.notificationTemplate, + limitSource: cost.limitSource, }) } } @@ -121,7 +120,7 @@ func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing bud } //nolint:gocritic // Enqueuing notifications requires the notifier actor. - if _, err := s.notifEnqueuer.EnqueueWithData(dbauthz.AsNotifier(ctx), crossing.userID, crossing.template, + if _, err := s.notifEnqueuer.EnqueueWithData(dbauthz.AsNotifier(ctx), crossing.userID, crossing.notificationTemplate, labels, nil, budgetNotificationsCreatedBy, crossing.effectiveGroupID, ); err != nil { From aadcf0c165128c18672a5641f33b52ab0879f232 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 23 Jul 2026 16:22:32 -0400 Subject: [PATCH 18/25] fix: drop limit_source from AI budget notifications, keep group (#27467) --- .../aibridgedserver/aibridgedserver_test.go | 71 ------------------- coderd/aibridgedserver/cost.go | 6 +- coderd/aibridgedserver/notifications.go | 4 -- .../000552_ai_budget_notifications.up.sql | 18 +---- coderd/notifications/notifications_test.go | 2 - ...mplateAIBudgetLimitReachedUser.html.golden | 9 +-- .../TemplateAIBudgetWarningUser.html.golden | 9 +-- ...mplateAIBudgetLimitReachedUser.json.golden | 5 +- .../TemplateAIBudgetWarningUser.json.golden | 5 +- 9 files changed, 13 insertions(+), 116 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 3a750da6faf..5bd96e81360 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2537,7 +2537,6 @@ func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { require.Equal(t, intc.InitiatorID, sent[0].UserID) require.Equal(t, "$1.00", sent[0].Labels["limit"]) require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"]) - require.Equal(t, string(codersdk.AIBudgetLimitSourceGroup), sent[0].Labels["limit_source"]) } }) } @@ -2624,76 +2623,6 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { require.Len(t, sent, 1, "expected the crossing to be detected against the interception's period") } -// TestRecordTokenUsageBudgetNotificationUserOverride verifies that when the -// limit comes from a per-user override, the notification reflects that. -func TestRecordTokenUsageBudgetNotificationUserOverride(t *testing.T) { - t.Parallel() - - const ( - spendLimitMicros int64 = 1_000_000 // $1 limit - warnAtMicros int64 = 850_000 // 85% of the limit - inputPriceMicros int64 = 1_000_000 // $1 per million tokens - ) - now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) - - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - enq := ¬ificationstest.FakeEnqueuer{} - - intc := newTestInterception(uuid.New()) - groupID := uuid.New() - override := &database.UserAIBudgetOverride{ - UserID: intc.InitiatorID, - GroupID: groupID, - SpendLimitMicros: spendLimitMicros, - } - price := &database.AIModelPrice{InputPrice: sql.NullInt64{Int64: inputPriceMicros, Valid: true}} - - expectTokenUsageCostLookups(db, intc, override, nil, 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: warnAtMicros}, nil) - db.EXPECT().GetGroupByID(gomock.Any(), groupID). - Return(database.Group{ID: groupID, Name: "Engineering"}, 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_override", - InputTokens: 1000, - CreatedAt: timestamppb.New(now), - }) - require.NoError(t, err) - - sent := enq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIBudgetWarningUser)) - require.Len(t, sent, 1, "expected one budget warning notification") - require.Equal(t, intc.InitiatorID, sent[0].UserID) - require.Equal(t, "$1.00", sent[0].Labels["limit"]) - require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"], - "attribution group must be surfaced even for an override") - require.Equal(t, string(codersdk.AIBudgetLimitSourceUserOverride), sent[0].Labels["limit_source"], - "limit source must be recorded so the template does not attribute the limit to the group") -} - // 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 286517b770b..79b59cd1b65 100644 --- a/coderd/aibridgedserver/cost.go +++ b/coderd/aibridgedserver/cost.go @@ -12,7 +12,6 @@ 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 @@ -25,7 +24,6 @@ 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 @@ -56,11 +54,9 @@ 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 and source used for threshold - // notifications. + // group carries the spend limit. 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 bdb37641e10..3e00c6d848d 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -13,7 +13,6 @@ 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 @@ -50,7 +49,6 @@ type budgetThresholdCrossing struct { spendLimitMicros int64 thresholdPercent int notificationTemplate uuid.UUID - limitSource codersdk.AIBudgetLimitSource } // detectBudgetThresholdCrossings checks whether this interception's cost pushed @@ -96,7 +94,6 @@ func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database spendLimitMicros: limit, thresholdPercent: t.percent, notificationTemplate: t.notificationTemplate, - limitSource: cost.limitSource, }) } } @@ -115,7 +112,6 @@ func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing bud "threshold": strconv.Itoa(crossing.thresholdPercent), "limit": formatSpendLimit(crossing.spendLimitMicros), "period": s.budgetPeriod.Adjective(), - "limit_source": string(crossing.limitSource), "effective_group_name": group.Name, } diff --git a/coderd/database/migrations/000552_ai_budget_notifications.up.sql b/coderd/database/migrations/000552_ai_budget_notifications.up.sql index 85e51dac451..1816a5a2ae4 100644 --- a/coderd/database/migrations/000552_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000552_ai_budget_notifications.up.sql @@ -13,14 +13,7 @@ VALUES ( 'b5db9597-de2a-4dea-87e9-25cee6906b86', 'AI Budget Warning', E'You''re approaching your {{.Labels.period}} AI budget limit', - $$You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). -{{- if eq .Labels.limit_source "group"}} - -This limit is set by your group **{{.Labels.effective_group_name}}**. -{{- else if eq .Labels.limit_source "user_override"}} - -This limit is set specifically for your account. -{{- end}}$$, + E'You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). Effective group: **{{.Labels.effective_group_name}}**.', '[]'::jsonb, 'AI Cost Control Events', NULL, @@ -43,14 +36,7 @@ VALUES ( 'cdcf2ecd-f003-4169-9800-abb2661ea522', 'AI Budget Limit Reached', E'You''ve reached your {{.Labels.period}} AI budget limit', - $$You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. -{{- if eq .Labels.limit_source "group"}} - -This limit is set by your group **{{.Labels.effective_group_name}}**. -{{- else if eq .Labels.limit_source "user_override"}} - -This limit is set specifically for your account. -{{- end}}$$, + E'You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. Effective group: **{{.Labels.effective_group_name}}**.', '[]'::jsonb, 'AI Cost Control Events', NULL, diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 6adecce4bc7..c72f1af4562 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1472,7 +1472,6 @@ func TestNotificationTemplates_Golden(t *testing.T) { "threshold": "85", "limit": "$1000.00", "period": "monthly", - "limit_source": "group", "effective_group_name": "Engineering", }, Data: map[string]any{}, @@ -1489,7 +1488,6 @@ func TestNotificationTemplates_Golden(t *testing.T) { "threshold": "100", "limit": "$1000.00", "period": "monthly", - "limit_source": "user_override", "effective_group_name": "Engineering", }, Data: map[string]any{}, diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden index 07e5fc7195c..2e85771d84b 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -13,9 +13,7 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, You have reached your monthly AI budget limit ($1000.00). Subsequent reques= -ts will be blocked. - -This limit is set specifically for your account. +ts will be blocked. Effective group: Engineering. --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -48,9 +46,8 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

You have reached your monthly AI budget limit ($1000.00). Subseq= -uent requests will be blocked.

- -

This limit is set specifically for your account.

+uent requests will be blocked. Effective group: Engineering.

=20 diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index c5c572756d0..7fb9cdac3ea 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -12,9 +12,8 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, -You have used more than 85% of your monthly AI budget ($1000.00). - -This limit is set by your group Engineering. +You have used more than 85% of your monthly AI budget ($1000.00). Effective= + group: Engineering. --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -47,9 +46,7 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

You have used more than 85% of your monthly AI budget ($1000.00)= -.

- -

This limit is set by your group Engineering.

+. Effective group: Engineering.

=20 diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden index 3ea508b9f96..87774ee95c8 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden @@ -13,7 +13,6 @@ "labels": { "effective_group_name": "Engineering", "limit": "$1000.00", - "limit_source": "user_override", "period": "monthly", "threshold": "100" }, @@ -22,6 +21,6 @@ }, "title": "You've reached your monthly AI budget limit", "title_markdown": "You've reached your monthly AI budget limit", - "body": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nThis limit is set specifically for your account.", - "body_markdown": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nThis limit is set specifically for your account." + "body": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: Engineering.", + "body_markdown": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: **Engineering**." } \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden index 0b2862f5fa5..6624805af81 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -13,7 +13,6 @@ "labels": { "effective_group_name": "Engineering", "limit": "$1000.00", - "limit_source": "group", "period": "monthly", "threshold": "85" }, @@ -22,6 +21,6 @@ }, "title": "You're approaching your monthly AI budget limit", "title_markdown": "You're approaching your monthly AI budget limit", - "body": "You have used more than 85% of your monthly AI budget ($1000.00).\n\nThis limit is set by your group Engineering.", - "body_markdown": "You have used more than 85% of your monthly AI budget ($1000.00).\n\nThis limit is set by your group **Engineering**." + "body": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: Engineering.", + "body_markdown": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: **Engineering**." } \ No newline at end of file From 52183351c346a26c3ef4b74c13dfb364dabc8143 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 24 Jul 2026 14:04:48 +0000 Subject: [PATCH 19/25] test: merge budget warning and limit notification tests --- .../aibridgedserver/aibridgedserver_test.go | 173 +++++------------- 1 file changed, 46 insertions(+), 127 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 5bd96e81360..bf4f2737ada 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2298,173 +2298,91 @@ func TestRecordTokenUsageAuthorized(t *testing.T) { require.Equal(t, wantCost, spend.SpendMicros, "spend micros") } -// TestRecordTokenUsageBudgetWarningNotification verifies that recording token -// usage that pushes the user's period spend across the 85% warning threshold -// enqueues a warning notification to the user, and that staying below the -// threshold does not. -func TestRecordTokenUsageBudgetWarningNotification(t *testing.T) { +// TestRecordTokenUsageBudgetNotifications verifies that recording token usage +// enqueues the right budget notifications: the warning template when spend +// crosses the warning threshold, the limit-reached template at 100%, both when +// a single interception crosses both, and nothing when no threshold is +// freshly crossed. +func TestRecordTokenUsageBudgetNotifications(t *testing.T) { t.Parallel() const ( - spendLimitMicros int64 = 1_000_000 // $1 limit + spendLimitMicros int64 = 1_000_000 // $1 limit (100% threshold) warnAtMicros int64 = 850_000 // 85% of the limit - inputTokens int64 = 1000 inputPriceMicros int64 = 1_000_000 // $1 per million tokens ) now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) - // inputTokens * inputPriceMicros / 1e6 -> this interception costs 1000 - // micros. newSpend is the post-increment period total; the code derives the - // pre-increment total as newSpend - 1000 and fires a warning only when it - // crosses warnAtMicros (pre < warnAtMicros <= post). + // $1 per million tokens means the interception cost in micros equals the + // input token count, so each case sets its cost via inputTokens. newSpend is + // the post-increment period total; the code derives the pre-increment total + // as newSpend - cost and fires a threshold only when the pre-increment total + // is below the threshold amount and the post-increment total is at or above it. price := &database.AIModelPrice{ InputPrice: sql.NullInt64{Int64: inputPriceMicros, Valid: true}, } + // Threshold percentage label expected for each template. + wantThreshold := map[uuid.UUID]string{ + notifications.TemplateAIBudgetWarningUser: "85", + notifications.TemplateAIBudgetLimitReachedUser: "100", + } + testCases := []struct { name string - newSpend int64 - wantNotified bool - wantThreshold string - wantLimit string + inputTokens int64 // also the interception cost in micros + newSpend int64 // post-increment period total + wantTemplates []uuid.UUID }{ { name: "crosses warning threshold", // pre = 850_500 - 1000 = 849_500 (< 850_000) - // post = 850_500 (>= 850_000) -> crosses. + // post = 850_500 (>= 850_000) -> warning. + inputTokens: 1000, newSpend: warnAtMicros + 500, - wantNotified: true, - wantThreshold: "85", - wantLimit: "$1.00", + wantTemplates: []uuid.UUID{notifications.TemplateAIBudgetWarningUser}, }, { - name: "crosses when post lands exactly on threshold", + name: "crosses warning threshold exactly", // pre = 850_000 - 1000 = 849_000 (< 850_000) - // post = 850_000 (>= 850_000) -> crosses. + // post = 850_000 (>= 850_000) -> warning. + inputTokens: 1000, newSpend: warnAtMicros, - wantNotified: true, - wantThreshold: "85", - wantLimit: "$1.00", + wantTemplates: []uuid.UUID{notifications.TemplateAIBudgetWarningUser}, }, { name: "stays below warning threshold", - // pre = 849_999 - 1000 = 848_999 (< 850_000) - // post = 849_999 (< 850_000) -> no crossing. - newSpend: warnAtMicros - 1, - wantNotified: false, + // post = 849_999 (< 850_000) -> no crossing. + inputTokens: 1000, + newSpend: warnAtMicros - 1, + wantTemplates: nil, }, { name: "already at warning threshold", - // pre = 851_000 - 1000 = 850_000 (not < 850_000) - // post = 851_000 -> no fresh crossing. - newSpend: warnAtMicros + 1000, - wantNotified: false, + // pre = 851_000 - 1000 = 850_000 (not < 850_000) -> no fresh crossing. + inputTokens: 1000, + newSpend: warnAtMicros + 1000, + wantTemplates: nil, }, { name: "already above warning threshold", - // pre = 860_000 - 1000 = 859_000 (>= 850_000) - // post = 860_000 -> no fresh crossing. - newSpend: warnAtMicros + 10000, - wantNotified: false, + // pre = 860_000 - 1000 = 859_000 (>= 850_000, < 1_000_000) -> no crossing. + inputTokens: 1000, + newSpend: warnAtMicros + 10_000, + wantTemplates: nil, }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - enq := ¬ificationstest.FakeEnqueuer{} - - intc := newTestInterception(uuid.New()) - groupID := uuid.New() - group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimitMicros} - - 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) - if tc.wantNotified { - db.EXPECT().GetGroupByID(gomock.Any(), groupID). - Return(database.Group{ID: groupID, Name: "Engineering"}, 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: inputTokens, - CreatedAt: timestamppb.New(now), - }) - require.NoError(t, err) - - sent := enq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIBudgetWarningUser)) - if !tc.wantNotified { - require.Empty(t, sent, "no budget warning notification expected") - return - } - require.Len(t, sent, 1, "expected one budget warning notification") - require.Equal(t, intc.InitiatorID, sent[0].UserID) - require.Equal(t, tc.wantThreshold, sent[0].Labels["threshold"]) - require.Equal(t, tc.wantLimit, sent[0].Labels["limit"]) - require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"]) - }) - } -} - -// TestRecordTokenUsageBudgetLimitReachedNotification verifies that crossing the -// 100% limit enqueues the limit-reached notification, and that a single -// interception crossing both the warning and limit thresholds enqueues both. -func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { - t.Parallel() - - const spendLimitMicros int64 = 1_000_000 // $1 limit (100% threshold) - now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) - - // $1 per million tokens means the interception cost in micros equals the - // input token count, so each case sets its cost via inputTokens. - price := &database.AIModelPrice{ - InputPrice: sql.NullInt64{Int64: 1_000_000, Valid: true}, - } - - testCases := []struct { - name string - inputTokens int64 // also the interception cost in micros - newSpend int64 // post-increment period total - wantTemplates []uuid.UUID - }{ { name: "crosses limit", - // pre = 999_500 (>= 850_000, so no warning; < 1_000_000) - // post = 1_000_500 (>= 1_000_000) -> limit only. + // pre = 1_000_500 - 1000 = 999_500 (>= 850_000, so no warning; < 1_000_000) + // post = 1_000_500 (>= 1_000_000) -> limit only. inputTokens: 1000, newSpend: spendLimitMicros + 500, wantTemplates: []uuid.UUID{notifications.TemplateAIBudgetLimitReachedUser}, }, { name: "crosses warning and limit in one interception", - // pre = 1_000_000 - 200_000 = 800_000 (< 850_000) - // post = 1_000_000 (>= 850_000 and >= 1_000_000) -> warning + limit. + // pre = 1_000_000 - 200_000 = 800_000 (< 850_000) + // post = 1_000_000 (>= 850_000 and >= 1_000_000) -> warning + limit. inputTokens: 200_000, newSpend: spendLimitMicros, wantTemplates: []uuid.UUID{ @@ -2474,7 +2392,7 @@ func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { }, { name: "already above limit", - // pre = 1_009_000 (>= 1_000_000) -> no fresh crossing. + // pre = 1_010_000 - 1000 = 1_009_000 (>= 1_000_000) -> no fresh crossing. inputTokens: 1000, newSpend: spendLimitMicros + 10_000, wantTemplates: nil, @@ -2535,6 +2453,7 @@ func TestRecordTokenUsageBudgetLimitReachedNotification(t *testing.T) { sent := enq.Sent(notificationstest.WithTemplateID(tmpl)) require.Len(t, sent, 1, "expected one notification for template %s", tmpl) require.Equal(t, intc.InitiatorID, sent[0].UserID) + require.Equal(t, wantThreshold[tmpl], sent[0].Labels["threshold"]) require.Equal(t, "$1.00", sent[0].Labels["limit"]) require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"]) } From 0e7d9164778b336aabb4b347c4e65558f0f1cbed Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 24 Jul 2026 15:09:18 +0000 Subject: [PATCH 20/25] test: express budget test amounts in dollar units --- .../aibridgedserver/aibridgedserver_test.go | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index bf4f2737ada..f7f5ce2751a 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2307,19 +2307,21 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) { t.Parallel() const ( - spendLimitMicros int64 = 1_000_000 // $1 limit (100% threshold) - warnAtMicros int64 = 850_000 // 85% of the limit - inputPriceMicros int64 = 1_000_000 // $1 per million tokens + 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) - // $1 per million tokens means the interception cost in micros equals the - // input token count, so each case sets its cost via inputTokens. newSpend is - // the post-increment period total; the code derives the pre-increment total - // as newSpend - cost and fires a threshold only when the pre-increment total - // is below the threshold amount and the post-increment total is at or above it. + // Each case sets its cost via inputTokens (tokensPerDollar tokens = $1). + // newSpend is the post-increment period total; the code derives the + // pre-increment total as newSpend - cost and fires a threshold only when the + // pre-increment total is below the threshold and the post-increment total is + // at or above it. price := &database.AIModelPrice{ - InputPrice: sql.NullInt64{Int64: inputPriceMicros, Valid: true}, + InputPrice: sql.NullInt64{Int64: inputPrice, Valid: true}, } // Threshold percentage label expected for each template. @@ -2330,61 +2332,57 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) { testCases := []struct { name string - inputTokens int64 // also the interception cost in micros + inputTokens int64 newSpend int64 // post-increment period total wantTemplates []uuid.UUID }{ { name: "crosses warning threshold", - // pre = 850_500 - 1000 = 849_500 (< 850_000) - // post = 850_500 (>= 850_000) -> warning. - inputTokens: 1000, - newSpend: warnAtMicros + 500, + // pre = $84.50 (< $85), post = $85.50 (>= $85) -> warning. + inputTokens: tokensPerDollar, + newSpend: warnAt + dollar/2, wantTemplates: []uuid.UUID{notifications.TemplateAIBudgetWarningUser}, }, { name: "crosses warning threshold exactly", - // pre = 850_000 - 1000 = 849_000 (< 850_000) - // post = 850_000 (>= 850_000) -> warning. - inputTokens: 1000, - newSpend: warnAtMicros, + // pre = $84 (< $85), post = $85 (>= $85) -> warning. + inputTokens: tokensPerDollar, + newSpend: warnAt, wantTemplates: []uuid.UUID{notifications.TemplateAIBudgetWarningUser}, }, { name: "stays below warning threshold", - // post = 849_999 (< 850_000) -> no crossing. - inputTokens: 1000, - newSpend: warnAtMicros - 1, + // post = $84.50 (< $85) -> no crossing. + inputTokens: tokensPerDollar, + newSpend: warnAt - dollar/2, wantTemplates: nil, }, { name: "already at warning threshold", - // pre = 851_000 - 1000 = 850_000 (not < 850_000) -> no fresh crossing. - inputTokens: 1000, - newSpend: warnAtMicros + 1000, + // pre = $85 (not < $85), post = $86 -> no fresh crossing. + inputTokens: tokensPerDollar, + newSpend: warnAt + dollar, wantTemplates: nil, }, { name: "already above warning threshold", - // pre = 860_000 - 1000 = 859_000 (>= 850_000, < 1_000_000) -> no crossing. - inputTokens: 1000, - newSpend: warnAtMicros + 10_000, + // pre = $89 (>= $85, < $100), post = $90 -> no crossing. + inputTokens: tokensPerDollar, + newSpend: warnAt + 5*dollar, wantTemplates: nil, }, { name: "crosses limit", - // pre = 1_000_500 - 1000 = 999_500 (>= 850_000, so no warning; < 1_000_000) - // post = 1_000_500 (>= 1_000_000) -> limit only. - inputTokens: 1000, - newSpend: spendLimitMicros + 500, + // pre = $99.50 (>= $85, so no warning; < $100), post = $100.50 (>= $100) -> limit. + inputTokens: tokensPerDollar, + newSpend: spendLimit + dollar/2, wantTemplates: []uuid.UUID{notifications.TemplateAIBudgetLimitReachedUser}, }, { name: "crosses warning and limit in one interception", - // pre = 1_000_000 - 200_000 = 800_000 (< 850_000) - // post = 1_000_000 (>= 850_000 and >= 1_000_000) -> warning + limit. - inputTokens: 200_000, - newSpend: spendLimitMicros, + // pre = $80 (< $85), post = $100 (>= $85 and >= $100) -> warning + limit. + inputTokens: 20 * tokensPerDollar, + newSpend: spendLimit, wantTemplates: []uuid.UUID{ notifications.TemplateAIBudgetWarningUser, notifications.TemplateAIBudgetLimitReachedUser, @@ -2392,9 +2390,9 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) { }, { name: "already above limit", - // pre = 1_010_000 - 1000 = 1_009_000 (>= 1_000_000) -> no fresh crossing. - inputTokens: 1000, - newSpend: spendLimitMicros + 10_000, + // pre = $109 (>= $100), post = $110 -> no fresh crossing. + inputTokens: tokensPerDollar, + newSpend: spendLimit + 10*dollar, wantTemplates: nil, }, } @@ -2409,7 +2407,7 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) { intc := newTestInterception(uuid.New()) groupID := uuid.New() - group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimitMicros} + group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimit} expectTokenUsageCostLookups(db, intc, nil, group, nil, price) @@ -2454,7 +2452,7 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) { require.Len(t, sent, 1, "expected one notification for template %s", tmpl) require.Equal(t, intc.InitiatorID, sent[0].UserID) require.Equal(t, wantThreshold[tmpl], sent[0].Labels["threshold"]) - require.Equal(t, "$1.00", sent[0].Labels["limit"]) + require.Equal(t, "$100.00", sent[0].Labels["limit"]) require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"]) } }) @@ -2469,9 +2467,11 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { t.Parallel() const ( - spendLimitMicros int64 = 1_000_000 // $1 limit - warnAtMicros int64 = 850_000 // 85% of the limit - inputPriceMicros int64 = 1_000_000 // $1 per million tokens + 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 ) // The interception was created in the final second of January but is @@ -2487,8 +2487,8 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { intc := newTestInterception(uuid.New()) groupID := uuid.New() - group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimitMicros} - price := &database.AIModelPrice{InputPrice: sql.NullInt64{Int64: inputPriceMicros, Valid: true}} + group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: spendLimit} + price := &database.AIModelPrice{InputPrice: sql.NullInt64{Int64: inputPrice, Valid: true}} expectTokenUsageCostLookups(db, intc, nil, group, nil, price) @@ -2506,7 +2506,7 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, p database.GetUserAISpendSinceParams) (database.GetUserAISpendSinceRow, error) { gotPeriodStart = p.PeriodStart - return database.GetUserAISpendSinceRow{SpendMicros: warnAtMicros}, nil + return database.GetUserAISpendSinceRow{SpendMicros: warnAt}, nil }) db.EXPECT().GetGroupByID(gomock.Any(), groupID). Return(database.Group{ID: groupID, Name: "Engineering"}, nil) @@ -2530,7 +2530,7 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { _, err = srv.RecordTokenUsage(ctx, &proto.RecordTokenUsageRequest{ InterceptionId: intc.ID.String(), MsgId: "msg_boundary", - InputTokens: 1000, + InputTokens: tokensPerDollar, CreatedAt: timestamppb.New(createdAt), }) require.NoError(t, err) From cd984877170d30b3ae561ee3ee15ea8c2a6e2c5f Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 24 Jul 2026 15:50:47 +0000 Subject: [PATCH 21/25] test: cover best-effort budget notification failures --- coderd/aibridgedserver/aibridgedserver.go | 2 +- .../aibridgedserver/aibridgedserver_test.go | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index f5d99ef396f..4094328a1de 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -437,7 +437,7 @@ func (s *Server) recordTokenUsageAndSpend(ctx context.Context, intc database.AIB for _, crossing := range crossings { if err := s.notifyBudgetThresholdCrossing(ctx, crossing); err != nil { - s.logger.Warn(ctx, "failed to send AI budget notification", + 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), diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index f7f5ce2751a..3002cb3c8f8 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2542,6 +2542,103 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) { require.Len(t, sent, 1, "expected the crossing to be detected against the interception's period") } +// TestRecordTokenUsageBudgetNotificationBestEffort verifies that a failure while +// detecting or sending a budget notification is swallowed: the token usage and +// spend are still recorded (RecordTokenUsage returns no error) and no +// notification is enqueued. This guards the best-effort contract, e.g. that a +// detection error is not propagated out of the transaction (which would roll +// back the committed spend). +func TestRecordTokenUsageBudgetNotificationBestEffort(t *testing.T) { + t.Parallel() + + const ( + dollar int64 = 1_000_000 + spendLimit int64 = 100 * dollar + warnAt int64 = 85 * dollar + inputPrice int64 = dollar + tokensPerDollar int64 = 1_000_000 + ) + now := time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) + price := &database.AIModelPrice{InputPrice: sql.NullInt64{Int64: inputPrice, Valid: true}} + + testCases := []struct { + name string + // spendSinceErr fails detection (the read inside the transaction); + // groupLookupErr fails the notification after a crossing is detected. + spendSinceErr error + groupLookupErr error + }{ + {name: "detection read fails", spendSinceErr: sql.ErrConnDone}, + {name: "group lookup fails", groupLookupErr: sql.ErrConnDone}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + enq := ¬ificationstest.FakeEnqueuer{} + + intc := newTestInterception(uuid.New()) + groupID := uuid.New() + 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) }, + ) + // The token usage and spend are recorded before detection runs. + 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) + + switch { + case tc.spendSinceErr != nil: + // Detection fails; the group is never looked up. + db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). + Return(database.GetUserAISpendSinceRow{}, tc.spendSinceErr) + case tc.groupLookupErr != nil: + // A crossing is detected ($84 -> $85), but resolving the group + // for the notification fails. + db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()). + Return(database.GetUserAISpendSinceRow{SpendMicros: warnAt}, nil) + db.EXPECT().GetGroupByID(gomock.Any(), groupID). + Return(database.Group{}, tc.groupLookupErr) + default: + t.Fatal("test case must set spendSinceErr or groupLookupErr") + } + + 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, + // The detect/notify failure is logged; ignore it here since + // triggering it is the point of the test. + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + Clock: quartz.NewReal(), + }) + require.NoError(t, err) + + // The failure must not surface as an error from RecordTokenUsage. + _, err = srv.RecordTokenUsage(ctx, &proto.RecordTokenUsageRequest{ + InterceptionId: intc.ID.String(), + MsgId: "msg_123", + InputTokens: tokensPerDollar, + CreatedAt: timestamppb.New(now), + }) + require.NoError(t, err) + require.Empty(t, enq.Sent(), "no notification should be enqueued when detection or lookup fails") + }) + } +} + // newTestInterception returns an interception with a fixed initiator, provider, // and model for cost-attribution test setup. func newTestInterception(id uuid.UUID) database.AIBridgeInterception { From 25f871a23b684c6c30f4165ee10377d18b5eeb88 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 24 Jul 2026 16:55:28 +0000 Subject: [PATCH 22/25] test: assert zero budget limit sends no notification --- .../aibridgedserver/aibridgedserver_test.go | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 3002cb3c8f8..da8f341bdf5 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2639,6 +2639,58 @@ func TestRecordTokenUsageBudgetNotificationBestEffort(t *testing.T) { } } +// TestRecordTokenUsageBudgetNotificationZeroLimit verifies that a zero spend +// limit (used to block a group entirely) produces no budget notification: there +// is no meaningful threshold to cross, and such users are already blocked by +// pre-request enforcement. The token usage is still recorded. +func TestRecordTokenUsageBudgetNotificationZeroLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + enq := ¬ificationstest.FakeEnqueuer{} + + intc := newTestInterception(uuid.New()) + groupID := uuid.New() + // A zero limit blocks the group; there is no threshold to cross. + group := &database.GetHighestGroupAIBudgetByUserRow{GroupID: groupID, SpendLimitMicros: 0} + price := &database.AIModelPrice{InputPrice: sql.NullInt64{Int64: 1_000_000, Valid: true}} + + 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) }, + ) + // The spend is still recorded; detection then short-circuits on the zero + // limit without reading spend or looking up the group. + 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) + + 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: 1_000_000, // $1 at $1 per million tokens + CreatedAt: timestamppb.New(time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC)), + }) + require.NoError(t, err) + require.Empty(t, enq.Sent(), "a zero spend limit must not produce a notification") +} + // newTestInterception returns an interception with a fixed initiator, provider, // and model for cost-attribution test setup. func newTestInterception(id uuid.UUID) database.AIBridgeInterception { From 7eeb49a7ebaf55397605aadc0b428cc09dff8c2b Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 24 Jul 2026 15:17:21 -0400 Subject: [PATCH 23/25] feat: show budget period window in AI budget notifications (#27489) --- coderd/aibridgedserver/aibridgedserver_test.go | 4 ++++ coderd/aibridgedserver/notifications.go | 10 ++++++++++ .../000552_ai_budget_notifications.up.sql | 12 ++++++++++-- coderd/notifications/notifications_test.go | 4 ++++ .../TemplateAIBudgetLimitReachedUser.html.golden | 13 ++++++++++--- .../smtp/TemplateAIBudgetWarningUser.html.golden | 13 ++++++++++--- .../TemplateAIBudgetLimitReachedUser.json.golden | 6 ++++-- .../webhook/TemplateAIBudgetWarningUser.json.golden | 6 ++++-- 8 files changed, 56 insertions(+), 12 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index da8f341bdf5..1e54445b7ab 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -2454,6 +2454,10 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) { require.Equal(t, wantThreshold[tmpl], sent[0].Labels["threshold"]) require.Equal(t, "$100.00", sent[0].Labels["limit"]) require.Equal(t, "Engineering", sent[0].Labels["effective_group_name"]) + // The interception is recorded at 2026-06-25, so its budget + // period runs June 1 - July 1, 2026. + require.Equal(t, "June 1, 2026", sent[0].Labels["period_start"]) + require.Equal(t, "July 1, 2026", sent[0].Labels["period_end"]) } }) } diff --git a/coderd/aibridgedserver/notifications.go b/coderd/aibridgedserver/notifications.go index 3e00c6d848d..4481401408f 100644 --- a/coderd/aibridgedserver/notifications.go +++ b/coderd/aibridgedserver/notifications.go @@ -49,6 +49,10 @@ type budgetThresholdCrossing struct { spendLimitMicros int64 thresholdPercent int notificationTemplate uuid.UUID + // 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 @@ -94,6 +98,8 @@ func (s *Server) detectBudgetThresholdCrossings(ctx context.Context, tx database spendLimitMicros: limit, thresholdPercent: t.percent, notificationTemplate: t.notificationTemplate, + periodStart: period.Start, + periodEnd: period.End, }) } } @@ -113,6 +119,10 @@ func (s *Server) notifyBudgetThresholdCrossing(ctx context.Context, crossing bud "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"), } //nolint:gocritic // Enqueuing notifications requires the notifier actor. diff --git a/coderd/database/migrations/000552_ai_budget_notifications.up.sql b/coderd/database/migrations/000552_ai_budget_notifications.up.sql index 1816a5a2ae4..61535f4f72d 100644 --- a/coderd/database/migrations/000552_ai_budget_notifications.up.sql +++ b/coderd/database/migrations/000552_ai_budget_notifications.up.sql @@ -13,7 +13,11 @@ VALUES ( 'b5db9597-de2a-4dea-87e9-25cee6906b86', 'AI Budget Warning', E'You''re approaching your {{.Labels.period}} AI budget limit', - E'You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). Effective group: **{{.Labels.effective_group_name}}**.', + $$You have used more than {{.Labels.threshold}}% of your {{.Labels.period}} AI budget ({{.Labels.limit}}). + +Effective group: **{{.Labels.effective_group_name}}** + +AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$, '[]'::jsonb, 'AI Cost Control Events', NULL, @@ -36,7 +40,11 @@ VALUES ( 'cdcf2ecd-f003-4169-9800-abb2661ea522', 'AI Budget Limit Reached', E'You''ve reached your {{.Labels.period}} AI budget limit', - E'You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. Effective group: **{{.Labels.effective_group_name}}**.', + $$You have reached your {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked. + +Effective group: **{{.Labels.effective_group_name}}** + +AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$, '[]'::jsonb, 'AI Cost Control Events', NULL, diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index c72f1af4562..ac26ecca9fa 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1473,6 +1473,8 @@ func TestNotificationTemplates_Golden(t *testing.T) { "limit": "$1000.00", "period": "monthly", "effective_group_name": "Engineering", + "period_start": "July 1, 2026", + "period_end": "August 1, 2026", }, Data: map[string]any{}, }, @@ -1489,6 +1491,8 @@ func TestNotificationTemplates_Golden(t *testing.T) { "limit": "$1000.00", "period": "monthly", "effective_group_name": "Engineering", + "period_start": "July 1, 2026", + "period_end": "August 1, 2026", }, Data: map[string]any{}, }, diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden index 2e85771d84b..37db6f733cc 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -13,7 +13,11 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, You have reached your monthly AI budget limit ($1000.00). Subsequent reques= -ts will be blocked. Effective group: Engineering. +ts will be blocked. + +Effective group: Engineering + +AI budget period: July 1, 2026 - August 1, 2026 --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -46,8 +50,11 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

You have reached your monthly AI budget limit ($1000.00). Subseq= -uent requests will be blocked. Effective group: Engineering.

+uent requests will be blocked.

+ +

Effective group: Engineering

+ +

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

=20 diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index 7fb9cdac3ea..3927ab28e31 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -12,8 +12,11 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, -You have used more than 85% of your monthly AI budget ($1000.00). Effective= - group: Engineering. +You have used more than 85% of your monthly AI budget ($1000.00). + +Effective group: Engineering + +AI budget period: July 1, 2026 - August 1, 2026 --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -46,7 +49,11 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

You have used more than 85% of your monthly AI budget ($1000.00)= -. Effective group: Engineering.

+.

+ +

Effective group: Engineering

+ +

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

=20 diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden index 87774ee95c8..4c3470574d0 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedUser.json.golden @@ -14,6 +14,8 @@ "effective_group_name": "Engineering", "limit": "$1000.00", "period": "monthly", + "period_end": "August 1, 2026", + "period_start": "July 1, 2026", "threshold": "100" }, "data": {}, @@ -21,6 +23,6 @@ }, "title": "You've reached your monthly AI budget limit", "title_markdown": "You've reached your monthly AI budget limit", - "body": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: Engineering.", - "body_markdown": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked. Effective group: **Engineering**." + "body": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026", + "body_markdown": "You have reached your monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\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/TemplateAIBudgetWarningUser.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden index 6624805af81..b9b082b9cbe 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetWarningUser.json.golden @@ -14,6 +14,8 @@ "effective_group_name": "Engineering", "limit": "$1000.00", "period": "monthly", + "period_end": "August 1, 2026", + "period_start": "July 1, 2026", "threshold": "85" }, "data": {}, @@ -21,6 +23,6 @@ }, "title": "You're approaching your monthly AI budget limit", "title_markdown": "You're approaching your monthly AI budget limit", - "body": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: Engineering.", - "body_markdown": "You have used more than 85% of your monthly AI budget ($1000.00). Effective group: **Engineering**." + "body": "You have used more than 85% of your monthly AI budget ($1000.00).\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026", + "body_markdown": "You have used more than 85% of your 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 137e8cdd73be6e4090e86878aa488ac1e16cdb11 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 27 Jul 2026 11:04:25 -0400 Subject: [PATCH 24/25] Update coderd/aibridgedserver/aibridgedserver.go Co-authored-by: Cian Johnston --- coderd/aibridgedserver/aibridgedserver.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 4094328a1de..db120caa7ed 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -121,8 +121,6 @@ type Server struct { // derive the window over which user AI spend is aggregated. budgetPeriod codersdk.AIBudgetPeriod clock quartz.Clock - // notifEnqueuer enqueues notifications. It is never nil; NewServer defaults - // it to a no-op enqueuer. notifEnqueuer notifications.Enqueuer } From bef2d0a1bf8f78a182d87e887c08352a99d20caf Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 27 Jul 2026 15:10:32 +0000 Subject: [PATCH 25/25] ci: run make fmt --- coderd/aibridgedserver/aibridgedserver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index db120caa7ed..62f8e1132e1 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -119,8 +119,8 @@ type Server struct { budgetPolicy codersdk.AIBudgetPolicy // budgetPeriod is the deployment-configured budgeting period used to // derive the window over which user AI spend is aggregated. - budgetPeriod codersdk.AIBudgetPeriod - clock quartz.Clock + budgetPeriod codersdk.AIBudgetPeriod + clock quartz.Clock notifEnqueuer notifications.Enqueuer }