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.*
This commit is contained in:
Danny Kopping
2026-07-08 15:32:17 +02:00
committed by GitHub
parent 48f07e6e13
commit affb359d13
16 changed files with 952 additions and 191 deletions
+25 -10
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
@@ -109,14 +110,12 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command {
defer srv.Close()
// Fetch the initial provider set from coderd, retrying until
// success.
// TODO(AIGOV-465): the standalone gateway has no refresh trigger
// yet, so this runs once on startup.
clientFn := func() (aibridged.DRPCClient, error) {
return srv.ClientContext(signalCtx)
}
// success. Subsequent changes are delivered by the watch loop
// started below. The reloader's client acquisition honors the
// context of each Reload call, so loadProviders is bounded by
// signalCtx and the watch loop by watchCtx.
providerLogger := logger.Named("aibridge.providers")
reloader := agpl.NewPoolRPCReloader(pool, clientFn, vals.AI.BridgeConfig, providerLogger, metrics, providerMetrics)
reloader := agpl.NewPoolRPCReloader(pool, srv.ClientContext, vals.AI.BridgeConfig, providerLogger, metrics, providerMetrics)
if err := loadProviders(signalCtx, reloader, providerLogger, srv.Done()); err != nil {
if signalCtx.Err() != nil {
logger.Info(signalCtx, "shutting down standalone AI Gateway")
@@ -127,6 +126,23 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command {
mw := coderd.AIGatewayDataPlaneMiddleware(vals.AI.BridgeConfig)
// Watch coderd for provider changes and refresh the pool on each
// signal.
watchCtx, watchCancel := context.WithCancel(signalCtx)
var watchWG sync.WaitGroup
watchWG.Go(func() {
// srv.ClientContext observes watchCtx, so watchCancel below
// unblocks a pending client acquisition and drains this
// goroutine without relying on srv.Close.
if err := aibridged.WatchProviderReload(watchCtx, srv.ClientContext, reloader, providerLogger); err != nil && watchCtx.Err() == nil {
providerLogger.Warn(watchCtx, "ai provider watch loop exited", slog.Error(err))
}
})
defer func() {
watchCancel()
watchWG.Wait()
}()
// The standalone listener is dedicated to Gateway traffic, so
// the daemon is served at the root. The /api/v2/ai-gateway
// and /api/v2/aibridge/ aliases are added for compatibility
@@ -299,9 +315,8 @@ func resolveAIGatewayKey(key string, keyFile string) (string, error) {
// reload is retried with backoff. A successful empty provider list is a valid
// result and ends the loop.
//
// TODO(AIGOV-465): the standalone gateway has no provider-change refresh
// trigger yet, so this runs once on startup; provider add/enable will not
// propagate to a running standalone gateway.
// Subsequent provider changes are delivered by WatchProviderReload, started
// after this initial load returns.
func loadProviders(ctx context.Context, reloader aibridged.ProviderReloader, logger slog.Logger, aibridgedDone <-chan struct{}) error {
for r := retry.New(50*time.Millisecond, 10*time.Second); r.Wait(ctx); {
if err := reloader.Reload(ctx); err != nil {