-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: notify users when AI spend crosses the budget threshold #27346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
239eaf0
90ab015
dd514ae
8dc829a
c40737a
05bc337
6aa58d1
98ed750
aadb31f
6173158
6fd2bb7
4d05f29
3720ea0
da5a88d
2515e0a
9a04f6b
d3f659d
fc54c0c
733ce14
aadcf0c
5218335
0e7d916
cd98487
25f871a
7eeb49a
137e8cd
3b512ad
bef2d0a
8c5a1f3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -82,6 +83,7 @@ type store interface { | |
| GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) | ||
| 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) | ||
|
|
||
| // MCPConfigurator-related queries. | ||
| GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.ExternalAuthLink, error) | ||
|
|
@@ -117,15 +119,19 @@ 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 | ||
| } | ||
|
|
||
| // Options carries the dependencies required to construct an aibridged Server. | ||
| 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 | ||
|
|
@@ -137,6 +143,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 { | ||
|
|
@@ -158,6 +169,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 { | ||
|
|
@@ -357,7 +369,11 @@ 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 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(), | ||
| InterceptionID: intc.ID, | ||
|
|
@@ -400,8 +416,33 @@ 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. | ||
|
Comment on lines
+420
to
+421
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we are testing this 🤔 this would be nice to have in the tests
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't sure this needed a test, but I added one anyway. It still uses |
||
| var detectErr error | ||
| crossings, detectErr = s.detectBudgetThresholdCrossings(ctx, tx, intc, cost, createdAt) | ||
| if detectErr != nil { | ||
| 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)) | ||
| } | ||
| return nil | ||
| }, nil) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| for _, crossing := range crossings { | ||
| if err := s.notifyBudgetThresholdCrossing(ctx, crossing); err != nil { | ||
|
evgeniy-scherbina marked this conversation as resolved.
|
||
| 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), | ||
| slog.Error(err)) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (s *Server) RecordPromptUsage(ctx context.Context, in *proto.RecordPromptUsageRequest) (*proto.RecordPromptUsageResponse, error) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.