feat: add CTAs to AI budget admin notifications (#28138)

## Problem

AI budget admin notifications tell an admin that a user crossed a budget
threshold, but give them nothing to act on: no link to the user's
budget, and no pointer to the docs.

## Changes

- Add a **Manage user AI budget** action to both admin templates,
linking to the effective group's members page filtered to the affected
user, where the per-user override is set.
- Link to the AI budget documentation from both notification bodies.
- Say when the block lifts in the limit-reached notification ("blocked
until `{{period_end}}`").
- Add an `organization_name` label, needed to build the members page
URL.
- Grant the AI Gateway daemon subject read access to organizations,
which the label lookup requires.
- Add an authorization test for the notification path, which also covers
the existing group read grant.

<details>
<summary>Screenshots</summary>

**Admin Notifications in Coder UI**

<img width="456" height="695" alt="admin_notifications"
src="https://github.com/user-attachments/assets/a579f91c-55cc-497c-87f4-235f15493725"
/>

**Admin Notification in Email**

<img width="660" height="399" alt="admin_notifications_email"
src="https://github.com/user-attachments/assets/39b876fb-f512-41e5-997f-a5269cd26ddc"
/>
</details>

Related to internal Slack threads:
* https://codercom.slack.com/archives/C096PFVBZKN/p1786051630688569
* https://codercom.slack.com/archives/C04M9CNGHGF/p1786453662306149

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by
@ssncferreira
This commit is contained in:
Susana Ferreira
2026-08-20 14:38:33 +01:00
committed by GitHub
parent 015d6a6e06
commit 363d7fc4d9
11 changed files with 235 additions and 13 deletions
@@ -85,6 +85,7 @@ type store interface {
GetUserEveryoneFallbackGroup(ctx context.Context, userID uuid.UUID) (uuid.UUID, error)
GetUserAISpendSince(ctx context.Context, arg database.GetUserAISpendSinceParams) (database.GetUserAISpendSinceRow, error)
GetGroupByID(ctx context.Context, id uuid.UUID) (database.Group, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error)
GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error)
// MCPConfigurator-related queries.
+100 -5
View File
@@ -2574,6 +2574,90 @@ func TestRecordTokenUsageAuthorized(t *testing.T) {
require.Equal(t, wantCost, spend.SpendMicros, "spend micros")
}
// TestBudgetNotificationAuthorized exercises the budget threshold notification
// path end-to-end against a real database through the dbauthz layer as
// subjectAibridged. This catches missing RBAC grants on the aibridged subject
// and verifies the group and organization labels round-trip from storage.
func TestBudgetNotificationAuthorized(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
rawDB, _ := dbtestutil.NewDB(t)
authzDB := dbauthz.New(rawDB, rbac.NewStrictAuthorizer(prometheus.NewRegistry()), logger, coderdtest.AccessControlStorePointer())
// Seed prerequisites via the raw (unauthorized) store. The user belongs to a
// group with a budget, so the effective group resolves to that group.
org := dbgen.Organization(t, rawDB, database.Organization{})
user := dbgen.User(t, rawDB, database.User{})
dbgen.OrganizationMember(t, rawDB, database.OrganizationMember{OrganizationID: org.ID, UserID: user.ID})
group := dbgen.Group(t, rawDB, database.Group{OrganizationID: org.ID})
dbgen.GroupMember(t, rawDB, database.GroupMemberTable{UserID: user.ID, GroupID: group.ID})
// The interception below costs 1555 micros, which crosses 85% of this limit
// (1530 micros) without reaching the limit itself.
_, err := rawDB.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{
GroupID: group.ID,
SpendLimitMicros: 1_800,
})
require.NoError(t, err, "upsert group AI budget")
const provider, model = "anthropic", "claude-sonnet-4-6"
priceSeed, err := json.Marshal([]map[string]any{{
"provider": provider,
"model": model,
"input_price": 3_000_000,
"output_price": 6_000_000,
"cache_read_price": 300_000,
"cache_write_price": 4_000_000,
}})
require.NoError(t, err)
require.NoError(t, rawDB.UpsertAIModelPrices(ctx, database.UpsertAIModelPricesParams{Seed: priceSeed, Source: database.AIModelPriceSourceDefault}), "seed model prices")
aiProvider := dbgen.AIProvider(t, rawDB, database.AIProvider{
Name: "anthropic-eu",
Type: database.AIProviderTypeAnthropic,
})
intc := dbgen.AIBridgeInterception(t, rawDB, database.InsertAIBridgeInterceptionParams{
InitiatorID: user.ID,
Provider: provider,
ProviderName: aiProvider.Name,
Model: model,
}, nil)
enq := &notificationstest.FakeEnqueuer{}
srv, err := aibridgedserver.NewServer(ctx, aibridgedserver.Options{
Store: authzDB,
AISeatTracker: agplaiseats.Noop{},
AccessURL: "/",
GatewayCfg: codersdk.AIBridgeConfig{},
Experiments: requiredExperiments,
Enqueuer: enq,
Logger: logger,
Clock: quartz.NewReal(),
})
require.NoError(t, err)
_, err = srv.RecordTokenUsage(ctx, &proto.RecordTokenUsageRequest{
InterceptionId: intc.ID.String(),
MsgId: "msg_budget_authz",
InputTokens: 100,
OutputTokens: 200,
CacheReadInputTokens: 50,
CacheWriteInputTokens: 10,
CreatedAt: timestamppb.New(time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC)),
})
require.NoError(t, err, "record token usage")
// Notification failures are logged rather than returned, so the enqueued
// message is the only signal that both lookups were authorized.
sent := enq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIBudgetWarningUser))
require.Len(t, sent, 1, "expected one budget warning notification")
require.Equal(t, group.Name, sent[0].Labels["effective_group_name"])
require.Equal(t, org.Name, sent[0].Labels["organization_name"])
}
// TestRecordTokenUsageModelPriceResolution covers which price an interception
// snapshots when a model carries a price from the embedded book, a price set
// through the API, or both.
@@ -2985,11 +3069,15 @@ func TestRecordTokenUsageBudgetNotifications(t *testing.T) {
Return(database.AIUserDailySpend{}, nil)
db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()).
Return(database.GetUserAISpendSinceRow{SpendMicros: tc.newSpend}, nil)
// The group and user are resolved once per interception that
// notifies, regardless of how many thresholds it crosses.
// The group, organization, and user are resolved once per
// interception that notifies, regardless of how many thresholds it
// crosses.
if len(tc.wantTemplates) > 0 {
orgID := uuid.New()
db.EXPECT().GetGroupByID(gomock.Any(), groupID).
Return(database.Group{ID: groupID, Name: "Engineering"}, nil)
Return(database.Group{ID: groupID, Name: "Engineering", OrganizationID: orgID}, nil)
db.EXPECT().GetOrganizationByID(gomock.Any(), orgID).
Return(database.Organization{ID: orgID, Name: "coder"}, nil)
db.EXPECT().GetUserByID(gomock.Any(), intc.InitiatorID).
Return(database.User{ID: intc.InitiatorID, Username: "bob"}, nil)
// No admins configured, so only the user is notified.
@@ -3083,8 +3171,11 @@ func TestRecordTokenUsageBudgetNotificationAcrossPeriodBoundary(t *testing.T) {
gotPeriodStart = p.PeriodStart
return database.GetUserAISpendSinceRow{SpendMicros: warnAt}, nil
})
orgID := uuid.New()
db.EXPECT().GetGroupByID(gomock.Any(), groupID).
Return(database.Group{ID: groupID, Name: "Engineering"}, nil)
Return(database.Group{ID: groupID, Name: "Engineering", OrganizationID: orgID}, nil)
db.EXPECT().GetOrganizationByID(gomock.Any(), orgID).
Return(database.Organization{ID: orgID, Name: "coder"}, nil)
db.EXPECT().GetUserByID(gomock.Any(), intc.InitiatorID).
Return(database.User{ID: intc.InitiatorID, Username: "bob"}, nil)
// No admins configured, so only the user is notified.
@@ -3366,8 +3457,11 @@ func TestRecordTokenUsageBudgetAdminNotification(t *testing.T) {
Return(database.AIUserDailySpend{}, nil)
db.EXPECT().GetUserAISpendSince(gomock.Any(), gomock.Any()).
Return(database.GetUserAISpendSinceRow{SpendMicros: tc.newSpend}, nil)
orgID := uuid.New()
db.EXPECT().GetGroupByID(gomock.Any(), groupID).
Return(database.Group{ID: groupID, Name: "Engineering"}, nil)
Return(database.Group{ID: groupID, Name: "Engineering", OrganizationID: orgID}, nil)
db.EXPECT().GetOrganizationByID(gomock.Any(), orgID).
Return(database.Organization{ID: orgID, Name: "coder"}, nil)
db.EXPECT().GetUserByID(gomock.Any(), intc.InitiatorID).
Return(database.User{ID: intc.InitiatorID, Username: "bob"}, nil)
db.EXPECT().GetUsers(gomock.Any(), database.GetUsersParams{
@@ -3409,6 +3503,7 @@ func TestRecordTokenUsageBudgetAdminNotification(t *testing.T) {
require.Equal(t, tc.wantThreshold, adminSent[0].Labels["threshold"])
require.Equal(t, "$100.00", adminSent[0].Labels["limit"])
require.Equal(t, "Engineering", adminSent[0].Labels["effective_group_name"])
require.Equal(t, "coder", adminSent[0].Labels["organization_name"])
require.Equal(t, tc.wantLimitSource, adminSent[0].Labels["limit_source"])
})
}
+5
View File
@@ -137,6 +137,10 @@ func (s *Server) notifyBudgetThresholdCrossings(ctx context.Context, crossings [
if err != nil {
return xerrors.Errorf("look up group %q: %w", effectiveGroupID, err)
}
org, err := s.store.GetOrganizationByID(ctx, group.OrganizationID)
if err != nil {
return xerrors.Errorf("look up organization %q: %w", group.OrganizationID, err)
}
user, err := s.store.GetUserByID(ctx, userID)
if err != nil {
return xerrors.Errorf("look up user %q: %w", userID, err)
@@ -158,6 +162,7 @@ func (s *Server) notifyBudgetThresholdCrossings(ctx context.Context, crossings [
"limit_source": string(c.limitSource),
"username": user.Username,
"effective_group_name": group.Name,
"organization_name": org.Name,
// Both bounds carry the year so a period straddling a year boundary
// (e.g. December 1, 2026 - January 1, 2027) is unambiguous.
"period_start": c.periodStart.UTC().Format("January 2, 2006"),
+1
View File
@@ -725,6 +725,7 @@ var (
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.
rbac.ResourceOrganization.Type: {policy.ActionRead}, // Required to name the organization in budget notification links.
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
@@ -0,0 +1,29 @@
UPDATE notification_templates
SET
body_template = $$User **{{.Labels.username}}** has used more than {{.Labels.threshold}}% of their {{.Labels.period}} AI budget ({{.Labels.limit}}).
Effective group: **{{.Labels.effective_group_name}}**
{{- if eq .Labels.limit_source "user_override"}}
This limit is a per-user override.
{{- end}}
AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$,
actions = '[]'::jsonb
WHERE
id = '2a7b0ac1-00e1-4625-9cd5-1e5933972c77';
UPDATE notification_templates
SET
body_template = $$User **{{.Labels.username}}** has reached their {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked.
Effective group: **{{.Labels.effective_group_name}}**
{{- if eq .Labels.limit_source "user_override"}}
This limit is a per-user override.
{{- end}}
AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}$$,
actions = '[]'::jsonb
WHERE
id = '0bafe0ea-a78b-4217-ad05-1ef12e92e025';
@@ -0,0 +1,43 @@
UPDATE notification_templates
SET
body_template = $$User **{{.Labels.username}}** has used more than {{.Labels.threshold}}% of their {{.Labels.period}} AI budget ({{.Labels.limit}}).
Effective group: **{{.Labels.effective_group_name}}**
{{- if eq .Labels.limit_source "user_override"}}
This limit is a per-user override.
{{- end}}
AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}
[How to configure AI budgets](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budget)$$,
actions = '[
{
"label": "Manage user AI budget",
"url": "{{base_url}}/organizations/{{.Labels.organization_name}}/groups/{{.Labels.effective_group_name}}?filter={{.Labels.username}}"
}
]'::jsonb
WHERE
id = '2a7b0ac1-00e1-4625-9cd5-1e5933972c77';
UPDATE notification_templates
SET
body_template = $$User **{{.Labels.username}}** has reached their {{.Labels.period}} AI budget limit ({{.Labels.limit}}). Subsequent requests will be blocked until {{.Labels.period_end}}.
Effective group: **{{.Labels.effective_group_name}}**
{{- if eq .Labels.limit_source "user_override"}}
This limit is a per-user override.
{{- end}}
AI budget period: {{.Labels.period_start}} - {{.Labels.period_end}}
Raise this user's limit to unblock them sooner. [How to configure AI budgets](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budget).$$,
actions = '[
{
"label": "Manage user AI budget",
"url": "{{base_url}}/organizations/{{.Labels.organization_name}}/groups/{{.Labels.effective_group_name}}?filter={{.Labels.username}}"
}
]'::jsonb
WHERE
id = '0bafe0ea-a78b-4217-ad05-1ef12e92e025';
@@ -1591,6 +1591,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
"period": "monthly",
"limit_source": "group",
"effective_group_name": "Engineering",
"organization_name": "coder",
"period_start": "July 1, 2026",
"period_end": "August 1, 2026",
},
@@ -1610,6 +1611,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
"period": "monthly",
"limit_source": "user_override",
"effective_group_name": "Engineering",
"organization_name": "coder",
"period_start": "July 1, 2026",
"period_end": "August 1, 2026",
},
@@ -13,7 +13,7 @@ Content-Type: text/plain; charset=UTF-8
Hi Bobby,
User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
requests will be blocked.
requests will be blocked until August 1, 2026.
Effective group: Engineering
@@ -21,6 +21,12 @@ This limit is a per-user override.
AI budget period: July 1, 2026 - August 1, 2026
Raise this user's limit to unblock them sooner. How to configure AI budgets=
(https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budget).
Manage user AI budget: http://test.com/organizations/coder/groups/Engineeri=
ng?filter=3Dalice
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
@@ -52,16 +58,28 @@ argin: 8px 0 32px; line-height: 1.5;">
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>User <strong>alice</strong> has reached their monthly AI budget =
limit ($1000.00). Subsequent requests will be blocked.</p>
limit ($1000.00). Subsequent requests will be blocked until August 1, 2026.=
</p>
<p>Effective group: <strong>Engineering</strong></p>
<p>This limit is a per-user override.</p>
<p>AI budget period: July 1, 2026 - August 1, 2026</p>
<p>Raise this user&rsquo;s limit to unblock them sooner. <a href=3D"https:/=
/coder.com/docs/ai-coder/ai-gateway/cost-controls#budget">How to configure =
AI budgets</a>.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/organizations/coder/groups/Engineering?f=
ilter=3Dalice" style=3D"display: inline-block; padding: 13px 24px; backgrou=
nd-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8p=
x; margin: 0 4px;">
Manage user AI budget
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
@@ -18,6 +18,12 @@ Effective group: Engineering
AI budget period: July 1, 2026 - August 1, 2026
How to configure AI budgets (https://coder.com/docs/ai-coder/ai-gateway/cos=
t-controls#budget)
Manage user AI budget: http://test.com/organizations/coder/groups/Engineeri=
ng?filter=3Dalice
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
@@ -54,9 +60,19 @@ hly AI budget ($1000.00).</p>
<p>Effective group: <strong>Engineering</strong></p>
<p>AI budget period: July 1, 2026 - August 1, 2026</p>
<p><a href=3D"https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budg=
et">How to configure AI budgets</a></p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/organizations/coder/groups/Engineering?f=
ilter=3Dalice" style=3D"display: inline-block; padding: 13px 24px; backgrou=
nd-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8p=
x; margin: 0 4px;">
Manage user AI budget
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
@@ -9,11 +9,17 @@
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [],
"actions": [
{
"label": "Manage user AI budget",
"url": "http://test.com/organizations/coder/groups/Engineering?filter=alice"
}
],
"labels": {
"effective_group_name": "Engineering",
"limit": "$1000.00",
"limit_source": "user_override",
"organization_name": "coder",
"period": "monthly",
"period_end": "August 1, 2026",
"period_start": "July 1, 2026",
@@ -24,6 +30,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
"body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
"body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
"body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked until August 1, 2026.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026\n\nRaise this user's limit to unblock them sooner. How to configure AI budgets (https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budget).",
"body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked until August 1, 2026.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026\n\nRaise this user's limit to unblock them sooner. [How to configure AI budgets](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budget)."
}
@@ -9,11 +9,17 @@
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [],
"actions": [
{
"label": "Manage user AI budget",
"url": "http://test.com/organizations/coder/groups/Engineering?filter=alice"
}
],
"labels": {
"effective_group_name": "Engineering",
"limit": "$1000.00",
"limit_source": "group",
"organization_name": "coder",
"period": "monthly",
"period_end": "August 1, 2026",
"period_start": "July 1, 2026",
@@ -25,6 +31,6 @@
},
"title": "alice is approaching their monthly AI budget limit",
"title_markdown": "alice is approaching their monthly AI budget limit",
"body": "User alice has used more than 85% of their monthly AI budget ($1000.00).\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
"body_markdown": "User **alice** has used more than 85% of their monthly AI budget ($1000.00).\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
"body": "User alice has used more than 85% of their monthly AI budget ($1000.00).\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026\n\nHow to configure AI budgets (https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budget)",
"body_markdown": "User **alice** has used more than 85% of their monthly AI budget ($1000.00).\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026\n\n[How to configure AI budgets](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#budget)"
}