mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5d40bac79f
### TL;DR Introduces an in-process `TransportFactory` for aibridge so that chatd (coder-agent LLM traffic) can route requests through the aibridged handler without crossing the HTTP route or requiring a license entitlement check. ### What changed? - Added a new `coderd/aibridge` package with a `TransportFactory` interface and a `Source` type for tagging the call site on request contexts. `SourceAgents` is defined as the constant for coder-agent traffic. - Implemented `NewTransportFactory` in `coderd/aibridged/transport.go`, which returns an `http.RoundTripper` that dispatches requests to the aibridged handler in-process. The response body is streamed through an `io.Pipe` so SSE/NDJSON/chunked responses propagate token-by-token. Handler panics are recovered and surfaced as 500 responses, and context cancellation closes the pipe with the appropriate error. - `RegisterInMemoryAIBridgedHTTPHandler` now also constructs a `TransportFactory` from the registered handler and stores it on `API.AIBridgeTransportFactory` (an `atomic.Pointer`), making it available to chatd without going through the license-gated HTTP route. - Added `API.AIBridgeTransportFactory` as a public `atomic.Pointer[aibridge.TransportFactory]` field on `coderd.API`. ### How to test? - `coderd/aibridged/transport_test.go` covers: transport creation, nil-handler errors, source attachment to context, header/status passthrough, streaming (SSE-style chunked writes visible before handler completion), context cancellation closing the body with an error, concurrent requests, handler panics producing 500s, and handlers that return without writing. - `coderd/aibridge_test.go` verifies that `AIBridgeTransportFactory` starts as nil on AGPL coderd, can be stored and loaded atomically, and that the stored factory correctly dispatches requests through the stub handler. ### Why make this change? Chatd needs to send LLM requests through aibridge in-process rather than via the external HTTP route, which is license-gated. The `TransportFactory` abstraction provides a clean seam: the entitlement check remains on the HTTP route for external callers, while in-process coder-agent traffic bypasses it through the factory. The `Source` type allows downstream handlers and logs to attribute traffic without gating behavior on the caller identity.
123 lines
4.6 KiB
Go
123 lines
4.6 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
|
|
"golang.org/x/xerrors"
|
|
"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"
|
|
)
|
|
|
|
// GetAIBridgedHandler returns the in-memory aibridge HTTP handler set by
|
|
// [API.RegisterInMemoryAIBridgedHTTPHandler], or nil if the daemon has not
|
|
// been wired in. Used by the enterprise /api/v2/aibridge route (license-gated)
|
|
// to forward requests into the same in-memory handler that chatd dispatches
|
|
// to in-process.
|
|
func (api *API) GetAIBridgedHandler() http.Handler {
|
|
return api.aibridgedHandler
|
|
}
|
|
|
|
// 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.aibridgedHandler = srv
|
|
|
|
factory := aibridged.NewTransportFactory(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, api.Database, api.Logger.Named("aibridgedserver"),
|
|
api.AccessURL.String(), api.DeploymentValues.AI.BridgeConfig, api.ExternalAuthConfigs, api.Experiments, api.AISeatTracker)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = aibridgedproto.DRPCRegisterRecorder(mux, srv)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("register recorder service: %w", err)
|
|
}
|
|
err = aibridgedproto.DRPCRegisterMCPConfigurator(mux, srv)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("register MCP configurator service: %w", err)
|
|
}
|
|
err = aibridgedproto.DRPCRegisterAuthorizer(mux, srv)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("register key validator service: %w", 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),
|
|
}, nil
|
|
}
|