Files
coder/coderd/aibridged.go
T
Danny Kopping affb359d13 feat: synchronise provider changes with WatchAIProviders (#27091)
## Why

PR #26797 was accidentally merged into the stale `graphite-base/26797`
branch instead of `main` (Graphite picked the wrong base), so its
changes never landed on `main`. This PR re-lands that work as a clean
cherry-pick onto the current `main`.

## What

Adds a `WatchAIProviders` streaming RPC to the `ProviderConfigurator`
service so a running standalone AI Gateway refetches its provider set
when the provider configuration changes. The server subscribes to
`AIProvidersChangedChannel` (published by the provider CRUD endpoints)
and forwards each event as a payload-free signal, plus one signal on
subscribe; the gateway calls `GetAIProviders` on each signal to rebuild
its pool. The aibridged API is bumped to v1.2.

Env-seeded providers don't need a signal: seeding finishes before coderd
serves the gateway connection, so the gateway's initial fetch already
reflects the seeded set.

## For reviewers

The change is split into two commits to make review easy:

1. **`feat: synchronise provider changes with WatchAIProviders`** is a
faithful cherry-pick of #26797, identical to the originally reviewed PR.
It is committed without pre-commit hooks because it does not build
against current `main` on its own.
2. **`fix: resolve cherry-pick conflicts against main`** contains only
the deltas needed to re-land on current `main`, and passes the full
pre-commit suite:
- `coderd/aibridged/proto/aibridged.pb.go` regenerated via the proto
make target (the cherry-picked copy was generated against the older
proto).
- `enterprise/cli/aigatewaystart.go` import block unioned; `main` added
`os` and `strings` while the PR added `sync`.
- Three `aibridgedserver.NewServer` test call sites that landed on
`main` after the original branch diverged now pass the new `pubsub`
argument.

Refs https://linear.app/codercom/issue/AIGOV-465

*This PR was produced by opencode (agent) using the
`anthropic/claude-opus-4-8` model, under human direction and review.*
2026-07-08 15:32:17 +02:00

113 lines
4.4 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, api.Database, api.Pubsub, api.Logger.Named("aibridgedserver"),
api.AccessURL.String(), api.DeploymentValues.AI.BridgeConfig, api.ExternalAuthConfigs, api.Experiments, api.AISeatTracker)
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
}