Skip to content

feat: notify users when AI spend crosses the budget threshold#27346

Open
evgeniy-scherbina wants to merge 12 commits into
mainfrom
yevhenii/cost-control-notifications
Open

feat: notify users when AI spend crosses the budget threshold#27346
evgeniy-scherbina wants to merge 12 commits into
mainfrom
yevhenii/cost-control-notifications

Conversation

@evgeniy-scherbina

@evgeniy-scherbina evgeniy-scherbina commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Implements: https://linear.app/codercom/issue/AIGOV-289/notify-users-and-admins-on-budget-warning-and-limit-reached

Notify users when their AI spend crosses a budget threshold for their effective group. Two thresholds are covered: a warning at 85%, and a limit-reached notification at 100%.

Detection runs on the post-response path, right after the interception's cost is added to the user's daily spend. It reads the user's AI spend on the same transaction where token usage is recorded and AI daily spend is incremented, and derives the pre-interception total by subtracting this interception's cost. In case of oldSpend < threshold && newSpend >= threshold - notification is sent. A single interception that crosses both thresholds enqueues both notifications.

Detection and delivery are best-effort: a failure is logged and never fails usage recording. The payload uses only stable values (the threshold percentage and the spend limit, not the exact spend), so duplicate enqueues are deduplicated by the notification system.

The two templates are added via migration and appear in each user's notification settings under the "AI Budget" group.

Admin notifications (owners and user admins) are a follow-up: #27415.

Screenshots:

image

@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cost-control-notifications branch from e4e2867 to 239eaf0 Compare July 21, 2026 13:51
@evgeniy-scherbina
evgeniy-scherbina marked this pull request as ready for review July 22, 2026 13:47
@evgeniy-scherbina

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-07-22 13:47 UTC by @evgeniy-scherbina
Spend: $32.20 / $100.00

Review history
  • R1 (2026-07-22): 19 reviewers, 6 Nit, 5 Note, 3 P2, 4 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | d485786..8dc829a

Last posted: Round 1, 18 findings (3 P2, 4 P3, 6 Nit, 5 Note), COMMENT. Review

Finding inventory

Finding inventory - PR #27346

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open aibridgedserver.go:439 Edge-triggered crossing + best-effort delivery drops the notification permanently on a transient failure R1 Nami P2, Meruem P2, Mafuuu P2, Chopper P3, Pariston P3, Bisky P2, Komugi Note Yes
CRF-2 P2 Open notifications.go:79 oldSpend = newSpend - cost reconstruction unsound under cross-day concurrency (READ COMMITTED) and createdAt-vs-clock.Now period mismatch R1 Knov P2, Mafuuu P2, Knuckle P3, Pariston P3, Hisoka P3, Komugi P3, Melody P3, Chopper Note Yes
CRF-3 P2 Open notifications.go:101 Notification path uses deployment-wide AsSystemRestricted when a scoped AsNotifier + group read would do R1 Kurapika P2 Yes
CRF-4 P3 Open migrations/000551...up.sql:16 Templates hardcode "monthly" while budget period is a configurable enum; no mechanical guard R1 Meruem P3, Netero/Hisoka/Mafuuu/Pariston/Chopper/Leorio/Luffy/Knov Note Yes
CRF-5 P3 Open migrations/000551...up.sql:16 "Effective group" leaks jargon and is misleading when the limit is a per-user override R1 Leorio P3, Luffy P3, Mafuuu Note Yes
CRF-6 P3 Open migrations/000551...up.sql:39 Limit-reached email states requests are blocked but gives no reset date, CTA, or next step R1 Leorio P3, Chopper P3, Luffy Note Yes
CRF-7 P3 Open notifications.go:125 formatSpendLimit is a 4th micros-to-USD copy and uses float division (rounding) vs the decimal chatd helper R1 Robin P3, Gon Nit Yes
CRF-8 Nit Open notifications_test.go:1487 Limit-reached golden payload omits the threshold label production always sends R1 Mafu-san Note, Mafuuu Nit Yes
CRF-9 Nit Open migrations/000551...up.sql:16 Warning body says "more than {threshold}%" but the crossing fires at >= R1 Mafuuu Nit Yes
CRF-10 Nit Open migrations/000551...up.sql:18 Group name "AI Budget" breaks the " Events" convention of every other group R1 Nami Nit Yes
CRF-11 Nit Open aibridgedserver.go:426 Inline comment restates the block comment four lines above verbatim in meaning R1 Gon P2 (downgraded) Yes
CRF-12 Nit Open aibridgedserver_test.go:2222 inputTokens const is dead; the request hardcodes 1000 R1 Gon Nit Yes
CRF-13 Nit Open aibridgedserver_test.go:2311 Warning test never asserts the total enq.Sent() count, so a stray notification would pass R1 Bisky Nit Yes
CRF-14 Note Open notifications.go:25 budgetNotificationsCreatedBy = "aigateway" is a third spelling for the subsystem R1 Gon Note Yes
CRF-15 Note Open aibridgedserver.go:147 nil Enqueuer silently degrades to a no-op, so a miswiring disables all budget notifications R1 Meruem Note Yes
CRF-16 Note Open notifications.go:14 85% / 100% thresholds are hardcoded with no configurability; confirm intent R1 Meruem Note Yes
CRF-17 Note Open notifications.go:84 A single interception crossing both thresholds sends contradictory "approaching" + "reached" at once R1 Nami Note Yes
CRF-18 Note Open notifications.go:111 group_name interpolated into **...**, so markdown chars in a group name render as garbled emphasis R1 Nami Note, Kurapika Note Yes
CRF-19 Nit Dropped by orchestrator (Gon at-naming line ref unreliable; minor) notifications.go:83 at is an unclear name for a monetary threshold R1 Gon Nit No

Contested and acknowledged

None this round.

Round log

Round 1

Netero-only first pass: no findings (2 Notes, mechanical floor clean), proceeded to panel.
Panel of 19 (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Chopper, Ging-go, Knuckle, Killua, Kurapika, Luffy, Komugi, Robin, Melody, Nami + wildcards Meruem, Knov). Ging-go and Killua: no findings.
3 P2, 4 P3, 6 Nit, 5 Note posted. Reviewed against d485786..8dc829a.
Verified against code: InTx defaults to READ COMMITTED (db.go DefaultTXOptions); IncrementUserAIDailySpend upserts per (user,group,day); spend day from createdAt, period from clock.Now(); ResolveUserAIBudget returns override.GroupID for user-override source; subjectNotifier lacks group read; chatd formatMicrosAsDollars uses decimal.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

AIGOV-289

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean, well-scoped feature: edge-triggered detection folded into the existing spend transaction, per-day DB dedupe, no new config surface, and both the AGPL and enterprise call sites wired. Test density is high (67%) and the boundary tables (exactly-on, just-below, already-at, already-above, crosses-both) genuinely fail if the crossing math breaks. The limit-reached copy even names its real consequence, and enforcement agrees with it: the 100% crossing fires at the same spend >= limit boundary that IsBudgetExceeded blocks on.

The panel's central concern is that the notification signal, which is the entire point of this PR, is not durable. It is edge-triggered (fires on exactly one interception) and both detected best-effort, so it can be lost or misfired three ways: a transient failure on the crossing interception (CRF-1), an unsound oldSpend = newSpend - cost reconstruction under cross-day concurrency and a createdAt-vs-clock.Now() period mismatch (CRF-2), and enforcement keeps blocking the user either way, so the failure mode is "blocked with no explanation." These converged from many angles (Knov, Mafuuu, Nami, Meruem at P2; Knuckle, Pariston, Hisoka, Komugi, Melody, Chopper below). I verified the load-bearing premises against the code: InTx(..., nil) runs at READ COMMITTED, IncrementUserAIDailySpend only row-locks the single (user, group, day) row, the spend day comes from createdAt while the period comes from s.clock.Now(), and subjectNotifier genuinely cannot read groups (which is why the path reaches for the wildcard actor in CRF-3). The durable fix for CRF-1 and CRF-2 is the same shape: make detection level-triggered from observed state (compare the highest threshold already notified this period against current spend, let the per-day dedupe drop repeats) and serialize the read+decision per user/period, rather than trusting a one-interception delta.

None of these block a merge on their own (no P0/P1), but CRF-1 through CRF-3 deserve a decision before this ships, not a follow-up assumption. Counts: 3 P2, 4 P3, 6 Nit, 5 Note.

Process: the PR description is empty (just the AI-contribution comment), so the reasoning the diff cannot show, why 85%, why best-effort inside the transaction, how duplicates are deduped, and the per-user-override behavior, is lost to the next reader. Per the repo PR style guide, please add it. The commit subject itself is good.

Bisky put the test gap best: "the suite is afraid of one thing, and it's the thing this PR is actually about" - no test exercises a failing detect or a failing notify, so changing return nil to return detectErr inside the transaction would roll back committed spend and no test would turn red.

This review was generated by Coder Agents.

🤖 This review was automatically generated with Coder Agents.

}

for _, crossing := range crossings {
if err := s.notifyBudgetThresholdCrossing(ctx, crossing); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [CRF-1] The budget notification is edge-triggered but delivered best-effort, so a single transient failure on the crossing interception drops it for the entire period. (Nami P2, Meruem P2, Mafuuu P2, Chopper P3, Pariston P3, Bisky P2)

Detection fires only on the one interception where oldSpend < at && newSpend >= at (notifications.go:84); every later interception has oldSpend >= at, so the edge never re-arms. This post-commit loop logs and swallows any error from GetGroupByID or EnqueueWithData, and the detect read inside the transaction is likewise swallowed (line 424-431). Meruem: "correctness (the user gets notified) depends on the enqueue succeeding on one specific interception, which nothing enforces."

Consequence: if that one send fails transiently, or the request-scoped ctx is cancelled once the gRPC call returns, the user hits their limit, gets blocked by IsBudgetExceeded, and the one message explaining why never arrives, with no retry. Bisky notes no test covers either failure branch (detect 82.4%, notify 75.0% coverage), so a regression here is invisible to CI.

Make detection level-triggered from observed state (detect newSpend >= at every interception and let the per-day dedupe_hash drop repeats, or record which thresholds fired this period) so a missed enqueue self-heals on the next interception. Alternatively, enqueue inside the transaction so it commits atomically with the spend. This needs a decision, not a silent best-effort acceptance.

🤖

@evgeniy-scherbina evgeniy-scherbina Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a best-effort approach is acceptable here. Users can always check their AI spend and budget limits in the UI.

  1. I don't like the idea of attempting to send a notification on every LLM request. It abuses the notification deduplication mechanism and would still result in notifications being sent every day.
  2. I also don't want to introduce additional state (warn85, warn100) because it adds unnecessary complexity. We'd need to add two database fields, manage their lifecycle, mark them as sent after a notification is delivered, and then reset them whenever the effective budget changes. Resetting them is particularly tricky because a user's budget can change implicitly when they're added to or removed from a group.
  3. I also think this is the convention throughout the Coder repository: notifications are treated as best-effort.

@ssncferreira wdyt, do you agree with current best-effort implementation?

limit := cost.spendLimitMicros.Int64
newSpend := spend.SpendMicros
// Pre-interception total is the current total minus this interception's cost.
oldSpend := newSpend - cost.costMicros.Int64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [CRF-2] oldSpend := newSpend - cost.costMicros is only correct if this interception is the sole writer inside the summed window, which breaks under cross-day concurrency and at period boundaries. (Knov P2, Mafuuu P2, Knuckle P3, Pariston P3, Hisoka P3, Komugi P3, Melody P3)

Two confirmed mechanisms:

  1. Cross-day concurrency. InTx(..., nil) runs at READ COMMITTED and IncrementUserAIDailySpend only row-locks the single (user, group, day) row (both verified). GetUserAISpendSince sums across all days in the period. Knov: with period total 80, limit 100, two concurrent interceptions (+3 on day N, +3 on day N+1) each read 83, neither observes >= 85, and after both commit the true total is 86, past the threshold, with no notification ever sent. Same-day interceptions serialize on the shared row and are fine; the gap is cross-day within one period.

  2. Clock-source mismatch. The spend row is bucketed by dbtime.StartOfDay(createdAt.UTC()) (aibridgedserver.go:415), but the period comes from budget.CurrentPeriod(s.clock.Now(), ...) (line 62). At a UTC month rollover (created 23:59 last day, processed 00:00 next day), the increment lands outside the new period's window, so newSpend excludes this cost while oldSpend = newSpend - cost goes negative, producing a spurious "limit reached / requests blocked" notification the user did not earn (Hisoka, Komugi, Melody), or the mirror missed-crossing case.

Knuckle's fix addresses both: take a pg_advisory_xact_lock keyed on the user/period at the top of the transaction and compute oldSpend as the period SUM read before the increment, and derive the period from the same time source the spend is bucketed by. Unverified only in that no reviewer reproduced the race; the mechanism is confirmed from the code.

🤖

@evgeniy-scherbina evgeniy-scherbina Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. This could be fixed by acquiring a lock in the recordTokenUsageAndSpend transaction:
if err := tx.AcquireLock(ctx, database.GenLockID(fmt.Sprintf(
	"ai-budget-spend:%s:%s",
	intc.InitiatorID,
	cost.effectiveGroupID.UUID,
))); err != nil {
	return xerrors.Errorf("acquire AI budget spend lock: %w", err)
}

That said, I think this race condition is very rare, so accepting the best-effort behavior without introducing the lock may be reasonable. @ssncferreira what do you think?

  1. Great catch, fixed here: 6aa58d1 and 98ed750

Comment thread coderd/aibridgedserver/notifications.go Outdated
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [CRF-3] The notification path runs under deployment-wide AsSystemRestricted to do two narrow things (read one group, enqueue one notification), when a purpose-scoped actor already exists. (Kurapika P2)

subjectSystemRestricted grants ResourceWildcard:{read} plus create/update/delete on users, API keys, workspaces, crypto keys, notification templates, and more. I confirmed subjectNotifier (AsNotifier), used by every other enqueue call site, holds only NotificationMessage, InboxNotification, WebpushSubscription, and DeploymentConfig, and cannot read groups, which is the only reason this path reaches for the wildcard actor. The //nolint comment asserts "enqueuing need[s] system access," but every other enqueuer disproves that.

recordTokenUsageAndSpend processes token-usage payloads fed from the aibridge daemon; any logic error on this path executes with read on every resource and write on users, keys, and workspaces. Kurapika's fix: add ResourceGroup:{read} to subjectNotifier and switch to AsNotifier, or better, carry group.Name out of budget.ResolveUserAIBudget (it already resolves the effective budget) so the path needs no group read at all and stays on AsNotifier unchanged.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prebuilds also uses AsSystemRestricted, but fixed here anyway: aadb31f

'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}}). Effective group: **{{.Labels.group_name}}**.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-4] Both templates hardcode "monthly" while the budget period is a configurable enum built to grow, with no mechanical guard tying the copy to the enum. (Meruem P3, and Note-level convergence from Netero, Hisoka, Mafuuu, Pariston, Chopper, Leorio, Luffy, Knov)

s.budgetPeriod is a codersdk.AIBudgetPeriod and detectBudgetThresholdCrossings already reads it to size the window. Today AIBudgetPeriods holds only "month", so the wording is accurate now, but notifyBudgetThresholdCrossing never passes the period as a label, so the template could not render it even if it wanted to. The day someone adds "week" or "day", spend resets on the new cadence and all four strings silently lie, with no compiler, runtime, or test signal (the golden files only assert the month wording). Meruem's mechanical fix: add the period as a label and template it (your {{.Labels.period}} AI budget), or pin len(codersdk.AIBudgetPeriods) == 1 in a test so growing the enum forces this file to be revisited.

🤖

@evgeniy-scherbina evgeniy-scherbina Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed here: 6173158 to avoid making additional DB migrations in the future

'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}}). Effective group: **{{.Labels.group_name}}**.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-5] "Effective group: {{.Labels.group_name}}" leaks internal resolver jargon and is actively misleading when the limit came from a per-user override. (Leorio P3, Luffy P3, Mafuuu Note)

I confirmed ResolveUserAIBudget returns early for a per-user override with Source: AIBudgetLimitSourceUserOverride but still carries override.GroupID, so this line renders "Effective group: Engineering" for a limit that did not come from the group at all, sending the user to the wrong place ("ask my group admin") when the override is what governs them. Luffy: "What's an effective group? That's a word from the budget resolver, not a word the person spending the money thinks in." Drop the phrase or state what it means to the reader; in the override case, either name the real source or omit the group line rather than assert a false cause.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the template - the new version looks better: 6fd2bb7

)

// budgetNotificationsCreatedBy records what enqueued AI budget notifications.
const budgetNotificationsCreatedBy = "aigateway"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-14] budgetNotificationsCreatedBy = "aigateway" is a third spelling for this subsystem. (Gon Note)

The package is aibridgedserver, the path is AI Bridge, and the persisted createdBy label is "aigateway". Sibling enqueuers use a stable identifier tied to the emitting subsystem. Since this value is stored with every notification, a third spelling makes provenance filtering harder later. Worth aligning on one name before it ships.

🤖

}

func NewServer(lifecycleCtx context.Context, opts Options) (*Server, error) {
enqueuer := opts.Enqueuer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-15] A nil Enqueuer silently degrades to a no-op, so a production miswiring disables every AI budget notification with no error. (Meruem Note)

NewServer substitutes notifications.NewNoopEnqueuer() when opts.Enqueuer == nil. Both real call sites wire it today, but a future call site that forgets would start clean and drop all budget notifications indefinitely, indistinguishable from "no thresholds crossed." The rest of this package injects explicit no-ops at the call site (e.g. agplaiseats.Noop{}) rather than relying on a nil default. Consider requiring a non-nil enqueuer, matching the AISeatTracker pattern.

🤖

"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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-16] The 85% warning and 100% limit thresholds are hardcoded with no configurability. (Meruem Note)

warningThresholdPercent = 85 bakes a product decision into a constant. Fine if deliberately fixed, but worth confirming it is intended to be non-tunable per deployment, since the budget limit itself is configurable per group.

🤖

var crossings []budgetThresholdCrossing
for _, t := range budgetThresholds {
at := limit * int64(t.percent) / 100
if oldSpend < at && newSpend >= at {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-17] A single large interception can cross both thresholds at once, so the user gets "you're approaching your limit" and "you've reached your limit" in the same instant. (Nami Note)

budgetThresholds is evaluated ascending and every crossed threshold is appended (documented as intentional). For a small budget or an expensive request, the two notifications have contradictory wording ("approaching" next to "reached"). Consider suppressing the warning when the limit-reached notification also fires on the same interception.

🤖

Comment thread coderd/aibridgedserver/notifications.go Outdated
labels := map[string]string{
"threshold": strconv.Itoa(crossing.thresholdPercent),
"limit": formatSpendLimit(crossing.spendLimitMicros),
"group_name": group.Name,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-18] group_name is interpolated into a markdown-bold span, so a group name with markdown characters renders as garbled emphasis. (Nami Note, Kurapika Note)

Group names are user-controlled and land in **{{.Labels.group_name}}**. A group named *prod* or a_b_c renders with stray or missing emphasis. Kurapika confirmed the markdown pipeline strips raw HTML (html.SkipHTML), so this is cosmetic, not injection, but it is still user-visible text.

🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant