Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions 148 cli/aibridged_dbsource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//go:build !slim

package cli

import (
"context"
"encoding/json"

"golang.org/x/xerrors"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/aibridge/config"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)

// buildProvidersFromDB constructs the list of aibridge providers from
// rows in the ai_providers table along with their associated keys.
// The deployment-values config is still consulted for circuit-breaker
// and BYOK-style settings that are global rather than per-provider.
//
// keysByProvider maps each provider's UUID to its ordered list of
// bearer keys (oldest first). Bedrock providers must map to an empty
// slice; non-Bedrock providers with an empty slice are skipped with a
// warning so the deployment can still serve the rest of the pool.
func buildProvidersFromDB(
rows []database.AIProvider,
keysByProvider map[string][]database.AIProviderKey,
cfg codersdk.AIBridgeConfig,
logger slog.Logger,
) ([]aibridge.Provider, error) {
var cbConfig *config.CircuitBreaker
if cfg.CircuitBreakerEnabled.Value() {
cbConfig = &config.CircuitBreaker{
FailureThreshold: uint32(cfg.CircuitBreakerFailureThreshold.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options.
Interval: cfg.CircuitBreakerInterval.Value(),
Timeout: cfg.CircuitBreakerTimeout.Value(),
MaxRequests: uint32(cfg.CircuitBreakerMaxRequests.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options.
}
}

out := make([]aibridge.Provider, 0, len(rows))
for _, row := range rows {
var settings codersdk.AIProviderSettings
if row.Settings.Valid && row.Settings.String != "" {
if err := json.Unmarshal([]byte(row.Settings.String), &settings); err != nil {
return nil, xerrors.Errorf("decode settings for %q: %w", row.Name, err)
}
}

keys := keysByProvider[row.ID.String()]
// firstKey holds the operator-preferred primary. The seed
// and CRUD layers preserve insertion order via monotonically
// increasing created_at, and GetAIProviderKeysByProviderID
// returns keys ordered by created_at ASC, so the first
// element is the primary.
firstKey := ""
if len(keys) > 0 {
firstKey = keys[0].APIKey
}

switch row.Type {
case database.AiProviderTypeOpenai:
if firstKey == "" {
logger.Warn(context.Background(),
"skipping enabled AI Bridge provider with no API keys; add one via /api/v2/aibridge/providers/{name}/keys",
slog.F("name", row.Name),
slog.F("type", "openai"),
)
continue
}
out = append(out, aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
Name: row.Name,
BaseURL: row.BaseUrl,
Key: firstKey,
CircuitBreaker: cbConfig,
SendActorHeaders: cfg.SendActorHeaders.Value(),
}))
case database.AiProviderTypeAnthropic:
var bedrock *aibridge.AWSBedrockConfig
if settings.Bedrock != nil {
bedrock = &aibridge.AWSBedrockConfig{
Region: settings.Bedrock.Region,
AccessKey: ptr.NilToEmpty(settings.Bedrock.AccessKey),
AccessKeySecret: ptr.NilToEmpty(settings.Bedrock.AccessKeySecret),
Model: settings.Bedrock.Model,
SmallFastModel: settings.Bedrock.SmallFastModel,
}
}
// Bedrock providers authenticate via settings and may
// have zero ai_provider_keys; that is the expected
// configuration. Non-Bedrock Anthropic providers must
// have at least one bearer key to be usable.
if bedrock == nil && firstKey == "" {
logger.Warn(context.Background(),
"skipping enabled AI Bridge provider with no API keys; add one via /api/v2/aibridge/providers/{name}/keys",
slog.F("name", row.Name),
slog.F("type", "anthropic"),
)
continue
}
out = append(out, aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
Name: row.Name,
BaseURL: row.BaseUrl,
Key: firstKey,
CircuitBreaker: cbConfig,
SendActorHeaders: cfg.SendActorHeaders.Value(),
}, bedrock))
default:
return nil, xerrors.Errorf("unknown provider type %q for provider %q", row.Type, row.Name)
}
}
return out, nil
}

// loadProvidersFromDB returns the current set of enabled, non-deleted
// AI Bridge providers from the database, converted into aibridge
// Provider instances. Each provider's API keys are loaded from
// ai_provider_keys in created_at order so the first key per provider
// is the operator-preferred primary.
func loadProvidersFromDB(
ctx context.Context,
db database.Store,
cfg codersdk.AIBridgeConfig,
logger slog.Logger,
) ([]aibridge.Provider, error) {
// The queries are authorized via dbauthz on ResourceAibridgeProvider.
// At reload time we run as the system actor because there is no
// user-facing request driving the reload.
//nolint:gocritic // server-side reload, no user actor available
sysCtx := dbauthz.AsSystemRestricted(ctx)
rows, err := db.GetAIProviders(sysCtx, database.GetAIProvidersParams{})
if err != nil {
return nil, xerrors.Errorf("get enabled ai providers: %w", err)
}
keysByProvider := make(map[string][]database.AIProviderKey, len(rows))
for _, row := range rows {
keys, err := db.GetAIProviderKeysByProviderID(sysCtx, row.ID)
if err != nil {
return nil, xerrors.Errorf("get ai provider keys for %q: %w", row.Name, err)
}
keysByProvider[row.ID.String()] = keys
}
return buildProvidersFromDB(rows, keysByProvider, cfg, logger)
}
23 changes: 21 additions & 2 deletions 23 cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1034,9 +1034,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// unconditionally when the bridge feature is enabled by config so
// chatd can use it regardless of license entitlement.
if vals.AI.BridgeConfig.Enabled.Value() {
providers, err := BuildProviders(vals.AI.BridgeConfig)
bridgeLogger := logger.Named("aibridge.reload")
providers, err := loadProvidersFromDB(ctx, options.Database, vals.AI.BridgeConfig, bridgeLogger)
if err != nil {
return xerrors.Errorf("build AI providers: %w", err)
return xerrors.Errorf("load ai providers from db: %w", err)
}
aibridgeDaemon, err := newAIBridgeDaemon(coderAPI, providers)
if err != nil {
Expand All @@ -1047,6 +1048,24 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// daemon does not affect in-flight requests but is needed to
// release pool/recorder resources at shutdown.
defer aibridgeDaemon.Close()

// Subscribe to ai_providers_changed pubsub events and reload
// the daemon's pool atomically. The proxy daemon (enterprise)
// is intentionally not reloaded yet; that comes in a follow-up
// that gives the proxy a Pooler interface too.
unsub, err := options.Pubsub.Subscribe(coderd.AIProvidersChangedChannel, func(notifyCtx context.Context, _ []byte) {
newProviders, err := loadProvidersFromDB(notifyCtx, options.Database, vals.AI.BridgeConfig, bridgeLogger)
if err != nil {
bridgeLogger.Warn(notifyCtx, "failed to reload ai bridge providers from db; keeping existing pool", slog.Error(err))
return
}
aibridgeDaemon.Reload(newProviders)
})
if err != nil {
bridgeLogger.Warn(ctx, "failed to subscribe to ai_providers_changed; pool will not hot-reload", slog.Error(err))
} else {
defer unsub()
}
}

if vals.Prometheus.Enable {
Expand Down
4 changes: 4 additions & 0 deletions 4 coderd/ai_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
aReq.New = row

auditAIProviderKeyChanges(ctx, r, *auditor, api.Logger, aiProviderKeyChanges{Added: keys})
publishAIProvidersChanged(ctx, api.Pubsub, api.Logger)

sdk, err := db2sdk.AIProvider(row, keys)
if err != nil {
Expand Down Expand Up @@ -400,6 +401,7 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
}

auditAIProviderKeyChanges(ctx, r, *auditor, api.Logger, keyChanges)
publishAIProvidersChanged(ctx, api.Pubsub, api.Logger)

sdk, err := db2sdk.AIProvider(updated, keys)
if err != nil {
Expand Down Expand Up @@ -453,6 +455,8 @@ func (api *API) aiProvidersDelete(rw http.ResponseWriter, r *http.Request) {
return
}

publishAIProvidersChanged(ctx, api.Pubsub, api.Logger)

rw.WriteHeader(http.StatusNoContent)
}

Expand Down
33 changes: 33 additions & 0 deletions 33 coderd/ai_providers_pubsub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package coderd

import (
"context"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database/pubsub"
)

// AIProvidersChangedChannel is the pubsub channel published whenever an
// ai_providers or ai_provider_keys row is inserted, updated, or
// soft-deleted via the API. Subscribers (currently aibridged in every
// replica, but the channel is provider-generic) refresh their cached
// state by re-querying the database.
//
// Messages have no payload; receivers re-read the rows themselves.
// This keeps the channel agnostic to dbcrypt-key changes and avoids
// bus traffic carrying secrets.
const AIProvidersChangedChannel = "ai_providers_changed"

// publishAIProvidersChanged publishes a notification on the providers-
// changed channel. Errors are logged but never returned to callers; a
// missed notification only delays consumers catching up to the new
// state, and the next mutation will retry.
func publishAIProvidersChanged(ctx context.Context, ps pubsub.Pubsub, logger slog.Logger) {
if ps == nil {
return
}
if err := ps.Publish(AIProvidersChangedChannel, nil); err != nil {
logger.Warn(ctx, "failed to publish ai_providers_changed",
slog.Error(err))
}
}
76 changes: 76 additions & 0 deletions 76 coderd/ai_providers_pubsub_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package coderd_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)

// TestAIProvidersPubsubPublish verifies that mutating an AI provider
// publishes on the AIProvidersChangedChannel so each replica's
// RequestBridge pool can invalidate.
func TestAIProvidersPubsubPublish(t *testing.T) {
t.Parallel()

db, ps := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

notified := make(chan struct{}, 4)
cancel, err := ps.Subscribe(coderd.AIProvidersChangedChannel, func(_ context.Context, _ []byte) {
select {
case notified <- struct{}{}:
default:
}
})
require.NoError(t, err)
t.Cleanup(cancel)

// Create publishes.
//nolint:gocritic // Owner role is the audience for this endpoint.
created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "pubsub-test",
Enabled: true,
BaseURL: "https://api.openai.com/v1",
})
require.NoError(t, err)
select {
case <-notified:
case <-ctx.Done():
t.Fatalf("timed out waiting for pubsub notify after create")
}

// Update publishes.
display := "Renamed"
//nolint:gocritic // Owner role is the audience for this endpoint.
_, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{
DisplayName: &display,
})
require.NoError(t, err)
select {
case <-notified:
case <-ctx.Done():
t.Fatalf("timed out waiting for pubsub notify after update")
}

// Delete publishes.
//nolint:gocritic // Owner role is the audience for this endpoint.
require.NoError(t, client.DeleteAIProvider(ctx, created.Name))
select {
case <-notified:
case <-ctx.Done():
t.Fatalf("timed out waiting for pubsub notify after delete")
}
}
17 changes: 17 additions & 0 deletions 17 coderd/aibridged/aibridged.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/retry"
)
Expand Down Expand Up @@ -154,6 +155,22 @@ func (s *Server) GetRequestHandler(ctx context.Context, req Request) (http.Handl
return reqBridge, nil
}

// Reload swaps the providers used to construct future RequestBridge
// instances and invalidates the existing cache. It is the entry
// point that the CLI subscribes to ai_providers_changed pubsub
// events on.
//
// Reload is safe to call concurrently with serving requests; existing
// in-flight requests continue against their previously-cached
// bridge until completion, while subsequent requests get a freshly-
// built bridge using the new provider set.
func (s *Server) Reload(providers []aibridge.Provider) {
if s.requestBridgePool == nil {
return
}
s.requestBridgePool.Reload(providers)
}

// isShutdown returns whether the Server is shutdown or not.
func (s *Server) isShutdown() bool {
select {
Expand Down
13 changes: 13 additions & 0 deletions 13 coderd/aibridged/aibridgedmock/poolmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.