mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: hot-reload aibridged and aibridgeproxyd providers on DB changes (#25673)
Previously the in-process aibridge daemon and the enterprise aibridgeproxy daemon both snapshotted their provider routing once at boot. Any `ai_providers` or `ai_provider_keys` mutation required a restart for either to pick it up. Add an `ai_providers_changed` pubsub channel that the CRUD handlers publish on after Create / Update / Delete. Both daemons subscribe: - **aibridged** rebuilds its `[]aibridge.Provider` snapshot via `BuildProviders` and swaps it into the pool atomically. Inflight requests keep serving against the bridge they already acquired; new acquires build against the new snapshot. Per-provider construction errors stay scoped to the offending row. - **aibridgeproxyd** rebuilds its routing snapshot from `GetAIProviders` and swaps the host→provider map atomically. The MITM listener picks up new providers without restart. DB read for aibridgeproxyd uses the existing `AsAIProviderMetadataReader` subject for routing-only access.
This commit is contained in:
@@ -4,27 +4,45 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/aibridge"
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/aibridge/intercept/apidump"
|
||||
"github.com/coder/coder/v2/coderd/aibridged"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/enterprise/aibridgeproxyd"
|
||||
"github.com/coder/coder/v2/enterprise/coderd"
|
||||
)
|
||||
|
||||
func newAIBridgeProxyDaemon(coderAPI *coderd.API, providers []aibridge.Provider) (*aibridgeproxyd.Server, error) {
|
||||
// aiBridgeProxyDaemon bundles the proxy server and its pubsub
|
||||
// subscription so both are torn down by a single Close call.
|
||||
type aiBridgeProxyDaemon struct {
|
||||
server *aibridgeproxyd.Server
|
||||
unsubscribe func()
|
||||
}
|
||||
|
||||
func (d *aiBridgeProxyDaemon) Close() error {
|
||||
if d.unsubscribe != nil {
|
||||
d.unsubscribe()
|
||||
}
|
||||
return d.server.Close()
|
||||
}
|
||||
|
||||
// newAIBridgeProxyDaemon starts the enterprise aibridge proxy daemon,
|
||||
// subscribes to ai_providers changes so the proxy's routing snapshot
|
||||
// tracks the database, and registers the HTTP handler on the API.
|
||||
// The returned io.Closer tears down both the subscription and server.
|
||||
func newAIBridgeProxyDaemon(coderAPI *coderd.API) (io.Closer, error) {
|
||||
ctx := context.Background()
|
||||
coderAPI.Logger.Debug(ctx, "starting in-memory aibridgeproxy daemon")
|
||||
|
||||
logger := coderAPI.Logger.Named("aibridgeproxyd")
|
||||
|
||||
domains, providerFromHost := domainsFromProviders(providers)
|
||||
|
||||
reg := prometheus.WrapRegistererWithPrefix("coder_aibridgeproxyd_", coderAPI.PrometheusRegistry)
|
||||
metrics := aibridgeproxyd.NewMetrics(reg)
|
||||
|
||||
@@ -36,55 +54,51 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API, providers []aibridge.Provider)
|
||||
}
|
||||
|
||||
srv, err := aibridgeproxyd.New(ctx, logger, aibridgeproxyd.Options{
|
||||
ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(),
|
||||
TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(),
|
||||
TLSKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSKeyFile.String(),
|
||||
CoderAccessURL: coderAPI.AccessURL.String(),
|
||||
MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(),
|
||||
MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(),
|
||||
DomainAllowlist: domains,
|
||||
AIBridgeProviderFromHost: providerFromHost,
|
||||
UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(),
|
||||
UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(),
|
||||
AllowedPrivateCIDRs: coderAPI.DeploymentValues.AI.BridgeProxyConfig.AllowedPrivateCIDRs.Value(),
|
||||
NewDumper: newDumper,
|
||||
Metrics: metrics,
|
||||
ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(),
|
||||
TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(),
|
||||
TLSKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSKeyFile.String(),
|
||||
CoderAccessURL: coderAPI.AccessURL.String(),
|
||||
MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(),
|
||||
MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(),
|
||||
UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(),
|
||||
UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(),
|
||||
AllowedPrivateCIDRs: coderAPI.DeploymentValues.AI.BridgeProxyConfig.AllowedPrivateCIDRs.Value(),
|
||||
NewDumper: newDumper,
|
||||
Metrics: metrics,
|
||||
RefreshProviders: refreshProxyProviders(coderAPI.Database),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to start in-memory aibridgeproxy daemon: %w", err)
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// domainsFromProviders extracts distinct hostnames from providers' base
|
||||
// URLs and builds a host-to-provider-name mapping function. The returned
|
||||
// domain list is suitable for use as DomainAllowlist and the mapping
|
||||
// function is suitable for use as AIBridgeProviderFromHost.
|
||||
func domainsFromProviders(providers []aibridge.Provider) ([]string, func(string) string) {
|
||||
hostToProvider := make(map[string]string, len(providers))
|
||||
var domains []string
|
||||
for _, p := range providers {
|
||||
raw := p.BaseURL()
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if _, exists := hostToProvider[host]; exists {
|
||||
// First provider wins; duplicates are expected when
|
||||
// multiple providers share a base URL host (e.g. two
|
||||
// OpenAI providers using the same proxy).
|
||||
continue
|
||||
}
|
||||
hostToProvider[host] = p.Name()
|
||||
domains = append(domains, host)
|
||||
unsubscribe, err := aibridged.SubscribeProviderReload(ctx, coderAPI.Pubsub, srv, logger.Named("provider-reload"))
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "subscribe aibridgeproxyd to ai providers change channel", slog.Error(err))
|
||||
unsubscribe = func() {}
|
||||
}
|
||||
|
||||
return domains, func(host string) string {
|
||||
return hostToProvider[strings.ToLower(host)]
|
||||
// Register the handler so coderd can serve the proxy endpoints.
|
||||
coderAPI.RegisterInMemoryAIBridgeProxydHTTPHandler(srv.Handler())
|
||||
|
||||
return &aiBridgeProxyDaemon{
|
||||
server: srv,
|
||||
unsubscribe: unsubscribe,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func refreshProxyProviders(db database.Store) aibridgeproxyd.RefreshProvidersFunc {
|
||||
return func(ctx context.Context) ([]aibridgeproxyd.ProviderRoute, error) {
|
||||
//nolint:gocritic // AsAIProviderMetadataReader is the correct subject for routing-only access.
|
||||
rows, err := db.GetAIProviders(dbauthz.AsAIProviderMetadataReader(ctx), database.GetAIProvidersParams{
|
||||
IncludeDisabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load ai providers: %w", err)
|
||||
}
|
||||
out := make([]aibridgeproxyd.ProviderRoute, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, aibridgeproxyd.ProviderRoute{Name: row.Name, BaseURL: row.BaseUrl})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/v2/aibridge"
|
||||
)
|
||||
|
||||
func TestDomainsFromProviders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ExtractsHostnames", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
providers := []aibridge.Provider{
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "openai", BaseURL: "https://api.openai.com/v1/"}),
|
||||
aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{Name: "anthropic", BaseURL: "https://api.anthropic.com/"}, nil),
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "custom", BaseURL: "https://custom-llm.example.com:8443/api"}),
|
||||
}
|
||||
|
||||
domains, mapping := domainsFromProviders(providers)
|
||||
|
||||
assert.Contains(t, domains, "api.openai.com")
|
||||
assert.Contains(t, domains, "api.anthropic.com")
|
||||
assert.Contains(t, domains, "custom-llm.example.com")
|
||||
|
||||
assert.Equal(t, "openai", mapping("api.openai.com"))
|
||||
assert.Equal(t, "anthropic", mapping("api.anthropic.com"))
|
||||
assert.Equal(t, "custom", mapping("custom-llm.example.com"))
|
||||
assert.Empty(t, mapping("unknown.com"))
|
||||
})
|
||||
|
||||
t.Run("DeduplicatesSameHost", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
providers := []aibridge.Provider{
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "first", BaseURL: "https://api.example.com/v1"}),
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "second", BaseURL: "https://api.example.com/v2"}),
|
||||
}
|
||||
|
||||
domains, mapping := domainsFromProviders(providers)
|
||||
|
||||
// Count occurrences of api.example.com.
|
||||
count := 0
|
||||
for _, d := range domains {
|
||||
if d == "api.example.com" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count)
|
||||
// First provider wins.
|
||||
assert.Equal(t, "first", mapping("api.example.com"))
|
||||
})
|
||||
|
||||
t.Run("CaseInsensitive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
providers := []aibridge.Provider{
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "provider", BaseURL: "https://API.Example.COM/v1"}),
|
||||
}
|
||||
|
||||
domains, mapping := domainsFromProviders(providers)
|
||||
|
||||
assert.Contains(t, domains, "api.example.com")
|
||||
assert.Equal(t, "provider", mapping("API.Example.COM"))
|
||||
assert.Equal(t, "provider", mapping("api.example.com"))
|
||||
})
|
||||
}
|
||||
+10
-17
@@ -15,7 +15,6 @@ import (
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/types/key"
|
||||
|
||||
agplcli "github.com/coder/coder/v2/cli"
|
||||
agplcoderd "github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
@@ -167,13 +166,14 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
|
||||
// in-memory roundtripper regardless of license); only the proxy
|
||||
// daemon remains enterprise-gated by config.
|
||||
if options.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() {
|
||||
// Seed env-derived providers before reading them back so the
|
||||
// proxy observes them on first startup. options.Database is
|
||||
// dbcrypt-wrapped at this point (set by coderd.New above),
|
||||
// so env-seeded keys are also written encrypted. Detached
|
||||
// ctx for the same reason as in agplcli below: an early
|
||||
// return would orphan newAPI's goroutines. Seeding is
|
||||
// idempotent; the agplcli path seeds again post-newAPI.
|
||||
// Seed env-derived providers before the proxy daemon's reloader
|
||||
// reads them back so the proxy observes them on first startup.
|
||||
// options.Database is dbcrypt-wrapped at this point (set by
|
||||
// coderd.New above), so env-seeded keys are also written
|
||||
// encrypted. Detached ctx for the same reason as in agplcli
|
||||
// below: an early return would orphan newAPI's goroutines.
|
||||
// Seeding is idempotent; the agplcli path seeds again
|
||||
// post-newAPI.
|
||||
//nolint:gocritic // Production timeout, not a test wait.
|
||||
aibridgeInitCtx, aibridgeInitCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer aibridgeInitCancel()
|
||||
@@ -185,19 +185,12 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
|
||||
); err != nil {
|
||||
return nil, nil, xerrors.Errorf("seed ai providers from env: %w", err)
|
||||
}
|
||||
providers, err := agplcli.BuildProviders(aibridgeInitCtx, options.Database, options.DeploymentValues.AI.BridgeConfig, options.Logger.Named("aibridge.providers"))
|
||||
if err != nil {
|
||||
return nil, nil, xerrors.Errorf("build AI providers: %w", err)
|
||||
}
|
||||
aiBridgeProxyServer, err := newAIBridgeProxyDaemon(api, providers)
|
||||
aiBridgeProxyCloser, err := newAIBridgeProxyDaemon(api)
|
||||
if err != nil {
|
||||
_ = closers.Close()
|
||||
return nil, nil, xerrors.Errorf("create aibridgeproxyd: %w", err)
|
||||
}
|
||||
closers.Add(aiBridgeProxyServer)
|
||||
|
||||
// Register the handler so coderd can serve the proxy endpoints.
|
||||
api.RegisterInMemoryAIBridgeProxydHTTPHandler(aiBridgeProxyServer.Handler())
|
||||
closers.Add(aiBridgeProxyCloser)
|
||||
}
|
||||
|
||||
return api.AGPL, closers, nil
|
||||
|
||||
Reference in New Issue
Block a user