mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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: <img width="1102" height="252" alt="image" src="https://github.com/user-attachments/assets/62291510-09ca-4cdf-a1f5-4bdc11a1db4b" /> <img width="466" height="384" alt="image" src="https://github.com/user-attachments/assets/030460ff-6fe2-4d59-b247-3550c543ef30" /> --------- Co-authored-by: Cian Johnston <cian@coder.com>
123 lines
4.6 KiB
Go
123 lines
4.6 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
|
|
"storj.io/drpc/drpcmux"
|
|
"storj.io/drpc/drpcserver"
|
|
|
|
"cdr.dev/slog/v3"
|
|
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
|
|
"github.com/coder/coder/v2/coderd/aibridged"
|
|
aibridgedproto "github.com/coder/coder/v2/coderd/aibridged/proto"
|
|
"github.com/coder/coder/v2/coderd/aibridgedserver"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
"github.com/coder/coder/v2/codersdk/drpcsdk"
|
|
)
|
|
|
|
// AIGatewayHandler returns the in-memory AI Gateway HTTP handler
|
|
// set by [API.RegisterInMemoryAIBridgedHTTPHandler], or nil if the daemon
|
|
// has not been wired in. Callers must apply their own [http.StripPrefix]
|
|
// for the route prefix they are mounting under.
|
|
func (api *API) AIGatewayHandler() http.Handler {
|
|
return api.aiGatewayHandler
|
|
}
|
|
|
|
// RegisterInMemoryAIBridgedHTTPHandler mounts [aibridged.Server]'s HTTP router onto
|
|
// [API]'s router, so that requests to aibridged will be relayed from Coder's API server
|
|
// to the in-memory aibridged.
|
|
//
|
|
// This also registers an in-process [agplaibridge.TransportFactory] so that
|
|
// chatd can route coder-agent LLM traffic through aibridge without crossing
|
|
// the HTTP route. No license entitlement gate is applied at the factory layer:
|
|
// the entitlement check stays on the HTTP route for external callers, while
|
|
// in-process coder-agent traffic is the explicit carve-out.
|
|
func (api *API) RegisterInMemoryAIBridgedHTTPHandler(srv http.Handler) {
|
|
if srv == nil {
|
|
panic("aibridged cannot be nil")
|
|
}
|
|
|
|
api.aiGatewayHandler = srv
|
|
|
|
factory := aibridged.NewTransportFactory(http.StripPrefix(agplaibridge.AIGatewayRootPath, srv))
|
|
var asInterface agplaibridge.TransportFactory = factory
|
|
api.AIBridgeTransportFactory.Store(&asInterface)
|
|
}
|
|
|
|
// CreateInMemoryAIBridgeServer creates a [aibridged.DRPCServer] and returns a
|
|
// [aibridged.DRPCClient] to it, connected over an in-memory transport.
|
|
// This server is responsible for all the Coder-specific functionality that aibridged
|
|
// requires such as persistence and retrieving configuration.
|
|
func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client aibridged.DRPCClient, err error) {
|
|
// TODO(dannyk): implement options.
|
|
// TODO(dannyk): implement tracing.
|
|
// TODO(dannyk): implement API versioning.
|
|
|
|
clientSession, serverSession := drpcsdk.MemTransportPipe()
|
|
defer func() {
|
|
if err != nil {
|
|
_ = clientSession.Close()
|
|
_ = serverSession.Close()
|
|
}
|
|
}()
|
|
|
|
mux := drpcmux.New()
|
|
srv, err := aibridgedserver.NewServer(api.ctx, aibridgedserver.Options{
|
|
Store: api.Database,
|
|
Pubsub: api.Pubsub,
|
|
AISeatTracker: api.AISeatTracker,
|
|
Enqueuer: api.NotificationsEnqueuer,
|
|
AccessURL: api.AccessURL.String(),
|
|
GatewayCfg: api.DeploymentValues.AI.BridgeConfig,
|
|
ExternalAuthConfigs: api.ExternalAuthConfigs,
|
|
Experiments: api.Experiments,
|
|
Logger: api.Logger.Named("aibridgedserver"),
|
|
Clock: api.Clock,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := aibridgedserver.Register(mux, srv); err != nil {
|
|
return nil, err
|
|
}
|
|
server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux},
|
|
drpcserver.Options{
|
|
Manager: drpcsdk.DefaultDRPCOptions(nil),
|
|
Log: func(err error) {
|
|
if errors.Is(err, io.EOF) {
|
|
return
|
|
}
|
|
api.Logger.Debug(dialCtx, "aibridged drpc server error", slog.Error(err))
|
|
},
|
|
},
|
|
)
|
|
// in-mem pipes aren't technically "websockets" but they have the same properties as far as the
|
|
// API is concerned: they are long-lived connections that we need to close before completing
|
|
// shutdown of the API.
|
|
api.WebsocketWaitMutex.Lock()
|
|
api.WebsocketWaitGroup.Add(1)
|
|
api.WebsocketWaitMutex.Unlock()
|
|
go func() {
|
|
defer api.WebsocketWaitGroup.Done()
|
|
// Here we pass the background context, since we want the server to keep serving until the
|
|
// client hangs up. The aibridged is local, in-mem, so there isn't a danger of losing contact with it and
|
|
// having a dead connection we don't know the status of.
|
|
err := server.Serve(context.Background(), serverSession)
|
|
api.Logger.Info(dialCtx, "aibridge daemon disconnected", slog.Error(err))
|
|
// Close the sessions, so we don't leak goroutines serving them.
|
|
_ = clientSession.Close()
|
|
_ = serverSession.Close()
|
|
}()
|
|
|
|
return &aibridged.Client{
|
|
Conn: clientSession,
|
|
DRPCRecorderClient: aibridgedproto.NewDRPCRecorderClient(clientSession),
|
|
DRPCMCPConfiguratorClient: aibridgedproto.NewDRPCMCPConfiguratorClient(clientSession),
|
|
DRPCAuthorizerClient: aibridgedproto.NewDRPCAuthorizerClient(clientSession),
|
|
DRPCProviderConfiguratorClient: aibridgedproto.NewDRPCProviderConfiguratorClient(clientSession),
|
|
}, nil
|
|
}
|