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
37 changes: 36 additions & 1 deletion coderd/aibridged/aibridged_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/coder/coder/v2/coderd/aibridged"
mock "github.com/coder/coder/v2/coderd/aibridged/aibridgedmock"
"github.com/coder/coder/v2/coderd/aibridged/proto"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
Expand Down Expand Up @@ -128,12 +129,38 @@ func TestServeHTTP_FailureModes(t *testing.T) {

// TODO: coderd connection-related failures.

// Budget-related failures.
{
name: "budget exceeded",
applyMocksFn: func(client *mock.MockDRPCClient, _ *mock.MockPooler) {
// Authorization passes.
client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsBudgetExceededResponse{
Exceeded: true,
SpendLimitMicros: ptr.Ref(int64(1_000)),
}, nil)
},
expectedErr: xerrors.New("AI budget of"),
expectedStatus: http.StatusForbidden,
},
{
name: "budget check failed",
applyMocksFn: func(client *mock.MockDRPCClient, _ *mock.MockPooler) {
// Authorization passes.
client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, xerrors.New("oops"))
},
expectedErr: aibridged.ErrBudgetCheck,
expectedStatus: http.StatusInternalServerError,
},

// Pool-related failures.
{
name: "pool instance",
applyMocksFn: func(client *mock.MockDRPCClient, pool *mock.MockPooler) {
// Should pass authorization.
// Should pass authorization and budget check.
client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsBudgetExceededResponse{}, nil)
// But fail when acquiring a pool instance.
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(nil, xerrors.New("oops"))
},
Expand Down Expand Up @@ -223,6 +250,7 @@ func TestServeHTTP_DelegatedAPIKey(t *testing.T) {
Username: "u",
}, nil
})
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).Return(&proto.IsBudgetExceededResponse{}, nil)
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, req aibridged.Request, _ aibridged.ClientFunc, _ aibridged.MCPProxyBuilder) (http.Handler, error) {
assert.Empty(t, req.SessionKey,
Expand Down Expand Up @@ -255,6 +283,7 @@ func TestServeHTTP_DelegatedAPIKey(t *testing.T) {
ApiKeyId: testKeyID,
Username: "u",
}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).Return(&proto.IsBudgetExceededResponse{}, nil)
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, req aibridged.Request, _ aibridged.ClientFunc, _ aibridged.MCPProxyBuilder) (http.Handler, error) {
assert.Equal(t, "coder-token-byok", req.SessionKey,
Expand Down Expand Up @@ -345,6 +374,7 @@ func TestServeHTTP_DelegatedAPIKey_BYOK_Integration(t *testing.T) {
Username: "u",
}, nil
})
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).Return(&proto.IsBudgetExceededResponse{}, nil)
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil)

factory := aibridged.NewTransportFactory(srv)
Expand Down Expand Up @@ -397,6 +427,7 @@ func TestServeHTTP_DelegatedAPIKey_Integration(t *testing.T) {
Username: "u",
}, nil
})
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).Return(&proto.IsBudgetExceededResponse{}, nil)
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil)

factory := aibridged.NewTransportFactory(srv)
Expand Down Expand Up @@ -483,6 +514,7 @@ func TestServeHTTP_StripCoderToken(t *testing.T) {
conn := &mockDRPCConn{}
client.EXPECT().DRPCConn().AnyTimes().Return(conn)
client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsBudgetExceededResponse{}, nil)
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(mockH, nil)

httpSrv := httptest.NewServer(srv)
Expand Down Expand Up @@ -677,6 +709,7 @@ func TestServeHTTP_ActorHeaders(t *testing.T) {
OwnerId: testUserID.String(),
Username: testUsername,
}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsBudgetExceededResponse{}, nil)
client.EXPECT().GetMCPServerConfigs(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.GetMCPServerConfigsResponse{}, nil)
client.EXPECT().RecordInterception(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.RecordInterceptionResponse{}, nil)
client.EXPECT().RecordInterceptionEnded(gomock.Any(), gomock.Any()).AnyTimes()
Expand Down Expand Up @@ -775,6 +808,7 @@ func TestRouting(t *testing.T) {
client.EXPECT().DRPCConn().AnyTimes().Return(conn)

client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsBudgetExceededResponse{}, nil)
client.EXPECT().GetMCPServerConfigs(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.GetMCPServerConfigsResponse{}, nil)
// This is the only recording we really care about in this test. This is called before the provider-specific logic processes
// the incoming request, and anything beyond that is the responsibility of coder/aibridge to test.
Expand Down Expand Up @@ -850,6 +884,7 @@ func TestServeHTTP_StripInternalHeaders(t *testing.T) {
conn := &mockDRPCConn{}
client.EXPECT().DRPCConn().AnyTimes().Return(conn)
client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
client.EXPECT().IsBudgetExceeded(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsBudgetExceededResponse{}, nil)
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(mockH, nil)

httpSrv := httptest.NewServer(srv)
Expand Down
15 changes: 15 additions & 0 deletions coderd/aibridged/aibridgedmock/clientmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 30 additions & 7 deletions coderd/aibridged/http.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package aibridged

import (
"fmt"
"net/http"
"strings"

"github.com/google/uuid"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/timestamppb"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/aibridge/recorder"
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/aibridged/proto"
"github.com/coder/coder/v2/coderd/database/dbtime"
)

var _ http.Handler = &Server{}
Expand All @@ -21,6 +24,7 @@ var (
ErrConnect = xerrors.New("could not connect to coderd")
ErrUnauthorized = xerrors.New("unauthorized")
ErrAcquireRequestHandler = xerrors.New("failed to acquire request handler")
ErrBudgetCheck = xerrors.New("internal server error checking user AI budget")
)

// ServeHTTP is the entrypoint for requests which will be intercepted by AI Bridge.
Expand Down Expand Up @@ -135,6 +139,32 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
return
}

id, err := uuid.Parse(resp.GetOwnerId())
if err != nil {
logger.Warn(ctx, "failed to parse user ID", slog.Error(err), slog.F("id", resp.GetOwnerId()))
http.Error(rw, ErrUnauthorized.Error(), http.StatusForbidden)
return
}
logger = logger.With(slog.F("user_id", id))

periodStart := dbtime.StartOfMonth(dbtime.Now().UTC())
Comment thread
ssncferreira marked this conversation as resolved.
budgetResp, err := client.IsBudgetExceeded(ctx, &proto.IsBudgetExceededRequest{
UserId: id.String(),
PeriodStart: timestamppb.New(periodStart),
})
if err != nil {
logger.Warn(ctx, "user AI budget check failed", slog.Error(err))
http.Error(rw, ErrBudgetCheck.Error(), http.StatusInternalServerError)
return
}
if budgetResp.GetExceeded() {
http.Error(rw, fmt.Sprintf(
"AI budget of US$%.2f exceeded. Please contact an administrator for more details.",
float64(budgetResp.GetSpendLimitMicros())/1_000_000,
), http.StatusForbidden)
return
}

// Rewire request context to include actor.
//
// [NOTE]
Expand All @@ -144,13 +174,6 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
"Username": resp.GetUsername(),
}))

id, err := uuid.Parse(resp.GetOwnerId())
if err != nil {
logger.Warn(ctx, "failed to parse user ID", slog.Error(err), slog.F("id", resp.GetOwnerId()))
http.Error(rw, ErrUnauthorized.Error(), http.StatusForbidden)
return
}

handler, err := s.GetRequestHandler(ctx, Request{
SessionKey: key,
APIKeyID: resp.ApiKeyId,
Expand Down
Loading
Loading