Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions coderd/aibridgedserver/aibridgedserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 100 additions & 5 deletions coderd/aibridgedserver/aibridgedserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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"])
})
}
Expand Down
5 changes: 5 additions & 0 deletions coderd/aibridgedserver/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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"}}
Comment thread
dannykopping marked this conversation as resolved.

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';
2 changes: 2 additions & 0 deletions coderd/notifications/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ 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

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
Expand Down Expand Up @@ -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;">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
"user_email": "[email protected]",
"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",
Expand All @@ -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)."
}
Loading
Loading