feat: fetch providers over DRPC (#26650)

Closes [AIGOV-455](https://linear.app/codercom/issue/AIGOV-455/extend-drpc-with-buildproviders).

## Why

The AI Gateway (`aibridged`) is being split into a standalone process that must not touch the database. `coderd` stays the source of truth and seeds the `ai_providers` / `ai_provider_keys` tables from the environment. This PR adds a DRPC call so the gateway fetches provider config from `coderd` instead of reading the DB, for both the embedded and standalone daemons.

## What

- **Proto:** new `ProviderConfigurator` service with a unary `GetAIProviders` RPC, plus `AIProvider` / `AIProviderBedrock` messages. `CurrentMinor` bumped to 1 (additive).
- **Server (`coderd/aibridgedserver`):** `GetAIProviders` runs a read-only `InTx` under `LockIDAIProvidersEnvSeed` so it never returns a mid-seed snapshot, reads providers (incl. disabled) plus keys for enabled ones, and maps to proto under `dbauthz.AsAIBridged`. Unmappable rows are skipped and logged; plaintext keys and Bedrock secrets are never logged.
- **Client:** `DRPCProviderConfiguratorClient` wired into the client union, `dialer.go`, and `CreateInMemoryAIBridgeServer`.
- **cli:** `BuildProvidersFromProto` maps the response through the existing DB-neutral `buildProvider`. A shared `poolRPCReloader` does the fetch/build/replace for both daemons: the embedded daemon reloads on every `ai_providers` change and fails startup if it cannot subscribe; the standalone gateway drives the same reloader once at startup, retrying until success and staying interruptible.
- **Dead code removed:** `BuildProvidersFromConfig`, `ProvidersFromConfig`, `AIProviderFromConfig`, and the DB-read `BuildProviders` path.
This commit is contained in:
Danny Kopping
2026-06-29 13:34:58 +02:00
committed by GitHub
parent 74b8f10d4e
commit ce94d42e19
22 changed files with 1288 additions and 408 deletions
+16 -37
View File
@@ -15,8 +15,6 @@ import (
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/aibridged"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
// StartTestAIBridgeDaemon wires an in-process aibridged daemon onto the
@@ -25,6 +23,10 @@ import (
// (e.g. chattest.NewOpenAI) will have their requests proxied through the real
// aibridged stack as they would in production.
//
// The daemon starts with an empty pool and fetches providers from coderd over
// the in-memory DRPC, then refreshes on ai_providers change events, exactly
// like cli.newAIBridgeDaemon.
//
// metrics is the registry the daemon reports provider reload events to.
// The caller owns the metrics instance and can assert on it after the daemon
// runs. Use [aibridged.NewMetrics] to create one, or nil for a throwaway.
@@ -40,27 +42,16 @@ func StartTestAIBridgeDaemon(
cfg := api.DeploymentValues.AI.BridgeConfig
tracer := otel.Tracer("aibridge-test")
providers, _, err := cli.BuildProviders(ctx, api.Database, cfg, logger, nil)
if err != nil {
t.Fatalf("build providers: %v", err)
if metrics == nil {
metrics = aibridged.NewMetrics(prometheus.NewRegistry())
}
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger.Named("pool"), nil, tracer)
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, nil, logger.Named("pool"), nil, tracer)
if err != nil {
t.Fatalf("create bridge pool: %v", err)
}
t.Cleanup(func() { _ = pool.Shutdown(context.Background()) })
if metrics == nil {
metrics = aibridged.NewMetrics(prometheus.NewRegistry())
}
reloader := &testPoolReloader{pool: pool, db: api.Database, cfg: cfg, logger: logger.Named("reloader"), metrics: metrics}
unsubscribe, err := aibridged.SubscribeProviderReload(ctx, api.Pubsub, reloader, logger.Named("subscriber"))
if err != nil {
t.Fatalf("subscribe provider reload: %v", err)
}
t.Cleanup(unsubscribe)
srv, err := aibridged.New(ctx, pool, func(dialCtx context.Context) (aibridged.DRPCClient, error) {
return api.CreateInMemoryAIBridgeServer(dialCtx)
}, logger, tracer)
@@ -69,26 +60,14 @@ func StartTestAIBridgeDaemon(
}
t.Cleanup(func() { _ = srv.Close() })
// The reloader fetches providers from coderd over srv's DRPC client; the
// subscription drives an initial load and refreshes on change events.
reloader := cli.NewPoolRPCReloader(pool, srv.Client, cfg, logger.Named("reloader"), nil, metrics)
unsubscribe, err := aibridged.SubscribeProviderReload(ctx, api.Pubsub, reloader, logger.Named("subscriber"))
if err != nil {
t.Fatalf("subscribe provider reload: %v", err)
}
t.Cleanup(unsubscribe)
api.RegisterInMemoryAIBridgedHTTPHandler(srv)
}
type testPoolReloader struct {
pool *aibridged.CachedBridgePool
db database.Store
cfg codersdk.AIBridgeConfig
logger slog.Logger
metrics *aibridged.Metrics
}
func (r *testPoolReloader) Reload(ctx context.Context) error {
// Stamp the attempt before building providers so the gap between
// attempt and success timestamps reveals a mid-reload hang.
r.metrics.RecordReloadAttempt()
providers, outcomes, err := cli.BuildProviders(ctx, r.db, r.cfg, r.logger, nil)
if err != nil {
return err
}
r.pool.ReplaceProviders(providers)
r.metrics.RecordReloadSuccess(outcomes)
return nil
}