feat: notify users when AI spend crosses the budget threshold#27346
feat: notify users when AI spend crosses the budget threshold#27346evgeniy-scherbina wants to merge 12 commits into
Conversation
e4e2867 to
239eaf0
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 18 findings (3 P2, 4 P3, 6 Nit, 5 Note), COMMENT. Review Finding inventoryFinding inventory - PR #27346Findings
Contested and acknowledgedNone this round. Round logRound 1Netero-only first pass: no findings (2 Notes, mechanical floor clean), proceeded to panel. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
I think a best-effort approach is acceptable here. Users can always check their AI spend and budget limits in the UI.
- 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.
- 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. - 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 |
There was a problem hiding this comment.
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:
-
Cross-day concurrency.
InTx(..., nil)runs at READ COMMITTED andIncrementUserAIDailySpendonly row-locks the single(user, group, day)row (both verified).GetUserAISpendSincesums 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 read83, 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. -
Clock-source mismatch. The spend row is bucketed by
dbtime.StartOfDay(createdAt.UTC())(aibridgedserver.go:415), but the period comes frombudget.CurrentPeriod(s.clock.Now(), ...)(line 62). At a UTC month rollover (created23:59last day, processed00:00next day), the increment lands outside the new period's window, sonewSpendexcludes this cost whileoldSpend = newSpend - costgoes 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.
🤖
There was a problem hiding this comment.
- This could be fixed by acquiring a lock in the
recordTokenUsageAndSpendtransaction:
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?
| // 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) |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
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}}**.', |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
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}}**.', |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
I updated the template - the new version looks better: 6fd2bb7
| ) | ||
|
|
||
| // budgetNotificationsCreatedBy records what enqueued AI budget notifications. | ||
| const budgetNotificationsCreatedBy = "aigateway" |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
🤖
| labels := map[string]string{ | ||
| "threshold": strconv.Itoa(crossing.thresholdPercent), | ||
| "limit": formatSpendLimit(crossing.spendLimitMicros), | ||
| "group_name": group.Name, |
There was a problem hiding this comment.
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.
🤖
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: