From ce94d42e19bf0182352f8825341d32a5331bff1e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jun 2026 13:34:58 +0200 Subject: [PATCH] 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. --- cli/aibridged.go | 340 +++++----- cli/aibridged_internal_test.go | 72 ++- cli/server.go | 6 +- cli/server_aibridge_internal_test.go | 74 +-- coderd/aibridged.go | 9 +- coderd/aibridged/aibridged.go | 6 +- coderd/aibridged/aibridgedmock/clientmock.go | 15 + coderd/aibridged/client.go | 2 + coderd/aibridged/proto/aibridged.pb.go | 580 ++++++++++++++---- coderd/aibridged/proto/aibridged.proto | 35 ++ coderd/aibridged/proto/aibridged_drpc.pb.go | 75 +++ coderd/aibridged/proto/version.go | 7 +- coderd/aibridged/reload.go | 9 +- coderd/aibridged/reload_test.go | 40 ++ coderd/aibridged/server.go | 1 + coderd/aibridgedserver/aibridgedserver.go | 137 +++++ .../aibridgedserver/aibridgedserver_test.go | 206 +++++++ coderd/aibridgedserver/register.go | 11 +- coderd/aibridgedtest/aibridgedtest.go | 53 +- coderd/coderdtest/swaggerparser.go | 1 + enterprise/cli/aibridgeproxyd.go | 7 +- enterprise/coderd/aibridgeserve.go | 10 +- 22 files changed, 1288 insertions(+), 408 deletions(-) diff --git a/cli/aibridged.go b/cli/aibridged.go index 9aa2ea5c27..87e583b573 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -6,7 +6,6 @@ import ( "context" "slices" - "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" @@ -16,9 +15,8 @@ import ( "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/aibridged" + "github.com/coder/coder/v2/coderd/aibridged/proto" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -26,11 +24,23 @@ import ( ) // newAIBridgeDaemon constructs the in-memory aibridge daemon and wires -// up a subscription that hot-reloads the provider pool from the -// database on every ai_providers change event. The returned unsubscribe +// up a subscription that hot-reloads the provider pool over the in-memory +// RPC on every ai_providers change event. The returned unsubscribe // function tears down the subscription; callers must invoke it // alongside Server.Close on shutdown. -func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg codersdk.AIBridgeConfig, reg prometheus.Registerer, metrics *aibridge.Metrics) (*aibridged.Server, func(), error) { +// +// Reloads fetch the provider set from coderd over the in-memory DRPC +// (GetAIProviders) rather than reading the database directly, so embedded and +// standalone gateways construct providers identically. Pubsub remains the +// hot-reload trigger. +// +// SubscribeProviderReload performs a best-effort initial reload synchronously, +// so the pool is populated before this returns whenever the fetch succeeds. +// That reload blocks on srv.Client(), but the embedded daemon's connection is +// an in-memory pipe that comes up immediately, and the env seed (which holds +// the seed lock) has already completed earlier in startup, so the wait is +// negligible. +func newAIBridgeDaemon(coderAPI *coderd.API, cfg codersdk.AIBridgeConfig, reg prometheus.Registerer, metrics *aibridge.Metrics) (*aibridged.Server, func(), error) { ctx := context.Background() coderAPI.Logger.Debug(ctx, "starting in-memory aibridge daemon") @@ -39,8 +49,10 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg providerMetrics := aibridged.NewMetrics(reg) tracer := coderAPI.TracerProvider.Tracer(tracing.TracerName) - // Create pool for reusable stateful [aibridge.RequestBridge] instances (one per user). - pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger.Named("pool"), metrics, tracer) // TODO: configurable size. + // Create an empty pool for reusable stateful [aibridge.RequestBridge] + // instances (one per user). The reloader populates it via the initial + // reload below. + pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, nil, logger.Named("pool"), metrics, tracer) // TODO: configurable size. if err != nil { return nil, nil, xerrors.Errorf("create request pool: %w", err) } @@ -48,147 +60,121 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg // Report current key pool state per provider at scrape time. reg.MustRegister(keypool.NewStateCollector(pool.KeyPools)) - // Subscribe to ai_providers change events so the pool tracks the - // database without a restart. The boot-time `providers` snapshot - // derives from env config and serves as a fallback if the database - // load fails inside the reloader. - reloader := &poolDBReloader{ - pool: pool, - db: coderAPI.Database, - cfg: cfg, - logger: logger.Named("provider-loader"), - aibridgeMetrics: metrics, - providerMetrics: providerMetrics, - } - unsubscribe, err := aibridged.SubscribeProviderReload(ctx, coderAPI.Pubsub, reloader, logger.Named("provider-reload")) - if err != nil { - // Pool is still usable with the boot-time snapshot; subscription - // failure is logged but not fatal so the daemon still serves. - logger.Warn(ctx, "subscribe to ai providers change channel", slog.Error(err)) - unsubscribe = func() {} - } - - // Create daemon. + // Create daemon. Construct it before subscribing so the reloader can use + // srv.Client() to fetch providers over the in-memory RPC. srv, err := aibridged.New(ctx, pool, func(dialCtx context.Context) (aibridged.DRPCClient, error) { return coderAPI.CreateInMemoryAIBridgeServer(dialCtx) }, logger, tracer) if err != nil { - unsubscribe() return nil, nil, xerrors.Errorf("start in-memory aibridge daemon: %w", err) } + + // Subscribe to ai_providers change events so the pool tracks the database + // without a restart, and perform the initial reload. The reload data path + // is the in-memory RPC. + reloader := NewPoolRPCReloader(pool, srv.Client, cfg, logger.Named("provider-loader"), metrics, providerMetrics) + unsubscribe, err := aibridged.SubscribeProviderReload(ctx, coderAPI.Pubsub, reloader, logger.Named("provider-reload")) + if err != nil { + // Without the subscription the pool can never track provider changes, + // so fail startup rather than serve a permanently stale snapshot. + _ = srv.Close() + return nil, nil, xerrors.Errorf("subscribe to ai providers change channel: %w", err) + } + return srv, unsubscribe, nil } -// poolDBReloader implements [aibridged.ProviderReloader] by loading -// the live provider set from the database and forwarding it to the -// pool. -type poolDBReloader struct { +// poolRPCReloader implements [aibridged.ProviderReloader] by fetching the +// live provider set from coderd over a DRPC client and forwarding it to the +// pool. It is shared by the embedded daemon (in-memory RPC, pubsub-triggered) +// and the standalone gateway (WebSocket RPC, retried at startup) so the fetch, +// build, replace, and reload-metric accounting live in one place. +type poolRPCReloader struct { pool *aibridged.CachedBridgePool - db database.Store + client func() (aibridged.DRPCClient, error) cfg codersdk.AIBridgeConfig logger slog.Logger aibridgeMetrics *aibridge.Metrics providerMetrics *aibridged.Metrics } -func (r *poolDBReloader) Reload(ctx context.Context) error { +// NewPoolRPCReloader builds an [aibridged.ProviderReloader] that fetches the +// provider set over the DRPC client returned by client and replaces pool's +// providers, recording reload metrics against providerMetrics. +func NewPoolRPCReloader( + pool *aibridged.CachedBridgePool, + client func() (aibridged.DRPCClient, error), + cfg codersdk.AIBridgeConfig, + logger slog.Logger, + aibridgeMetrics *aibridge.Metrics, + providerMetrics *aibridged.Metrics, +) aibridged.ProviderReloader { + return &poolRPCReloader{ + pool: pool, + client: client, + cfg: cfg, + logger: logger, + aibridgeMetrics: aibridgeMetrics, + providerMetrics: providerMetrics, + } +} + +func (r *poolRPCReloader) Reload(ctx context.Context) error { r.providerMetrics.RecordReloadAttempt() - providers, outcomes, err := BuildProviders(ctx, r.db, r.cfg, r.logger, r.aibridgeMetrics) + // r.client() blocks until the daemon is connected to coderd. + client, err := r.client() + if err != nil { + return xerrors.Errorf("get ai-gateway client: %w", err) + } + resp, err := client.GetAIProviders(ctx, &proto.GetAIProvidersRequest{}) if err != nil { // Keep the previous snapshot in place: dropping all providers - // because the DB read failed would compound the visible failure - // mode beyond the operator's actual misconfiguration. - return xerrors.Errorf("load ai providers from database: %w", err) + // because the fetch failed would compound the visible failure mode + // beyond the operator's actual misconfiguration. + return xerrors.Errorf("fetch ai providers: %w", err) } + providers, outcomes := BuildProvidersFromProto(ctx, resp.GetProviders(), r.cfg, r.logger, r.aibridgeMetrics) r.pool.ReplaceProviders(providers) r.providerMetrics.RecordReloadSuccess(outcomes) return nil } -// BuildProviders loads all ai_providers rows (enabled and disabled), -// attaches keys to enabled rows, and constructs the equivalent -// [aibridge.Provider] instances. The database is the single source of -// truth for runtime provider configuration. +// BuildProvidersFromProto constructs the runtime [aibridge.Provider] set from +// proto provider configuration. // -// Disabled rows produce a Provider stub with Enabled() == false so the +// Disabled entries produce a Provider stub with Enabled() == false so the // bridge can answer requests targeting them with a 503 sentinel. // -// Per-provider construction errors are logged and the offending row is -// excluded from the returned snapshot; only a failure of the DB query -// itself is propagated. This keeps a single misconfigured row from -// taking the whole daemon down. -func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger, metrics *aibridge.Metrics) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) { - //nolint:gocritic // AsAIBridged has a minimal permission set for this purpose. - authCtx := dbauthz.AsAIBridged(ctx) - - var rows []database.AIProvider - keysByProvider := make(map[uuid.UUID][]database.AIProviderKey) - - // Wrap both queries in a read-only transaction so the provider list - // and the key list are consistent with each other. - err := db.InTx(func(tx database.Store) error { - var err error - rows, err = tx.GetAIProviders(authCtx, database.GetAIProvidersParams{ - IncludeDisabled: true, - }) - if err != nil { - return xerrors.Errorf("load ai providers: %w", err) - } - - if len(rows) == 0 { - return nil - } - - // Load keys only for the enabled providers to avoid materializing - // secrets for disabled rows. - ids := make([]uuid.UUID, 0, len(rows)) - for _, r := range rows { - if !r.Enabled { - continue - } - ids = append(ids, r.ID) - } - if len(ids) == 0 { - return nil - } - keyRows, err := tx.GetAIProviderKeysByProviderIDs(authCtx, ids) - if err != nil { - return xerrors.Errorf("load ai provider keys: %w", err) - } - for _, k := range keyRows { - keysByProvider[k.ProviderID] = append(keysByProvider[k.ProviderID], k) - } - return nil - }, &database.TxOptions{ReadOnly: true, TxIdentifier: "build_ai_providers"}) - if err != nil { - return nil, nil, err - } - - providers := make([]aibridge.Provider, 0, len(rows)) - outcomes := make([]aibridged.ProviderOutcome, 0, len(rows)) +// Per-provider construction errors are logged and the offending entry is +// excluded from the returned snapshot; this keeps a single misconfigured +// provider from taking the whole daemon down. The returned outcomes mirror the +// per-provider status for metrics reporting. +func BuildProvidersFromProto(ctx context.Context, protoProviders []*proto.AIProvider, cfg codersdk.AIBridgeConfig, logger slog.Logger, metrics *aibridge.Metrics) ([]aibridge.Provider, []aibridged.ProviderOutcome) { + providers := make([]aibridge.Provider, 0, len(protoProviders)) + outcomes := make([]aibridged.ProviderOutcome, 0, len(protoProviders)) enabledCount := 0 - for _, row := range rows { + for _, pp := range protoProviders { + spec := protoToProviderSpec(pp) outcome := aibridged.ProviderOutcome{ - Name: row.Name, - Type: string(row.Type), + Name: spec.Name, + Type: string(spec.Type), } - if row.Enabled { + if spec.Enabled { enabledCount++ } - prov, err := buildAIProviderFromRow(ctx, row, keysByProvider[row.ID], cfg, metrics) + prov, err := buildProvider(ctx, spec, cfg, metrics) if err != nil { outcome.Status = aibridged.ProviderStatusError outcome.Err = err outcomes = append(outcomes, outcome) logger.Error(ctx, "skipping misconfigured ai provider", - slog.F("provider_id", row.ID), - slog.F("provider_name", row.Name), - slog.F("provider_type", string(row.Type)), + slog.F("provider_name", spec.Name), + slog.F("provider_type", string(spec.Type)), slog.Error(err), ) continue } - if row.Enabled { + if spec.Enabled { outcome.Status = aibridged.ProviderStatusEnabled } else { outcome.Status = aibridged.ProviderStatusDisabled @@ -201,28 +187,56 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg logger.Warn(ctx, "all enabled ai providers failed to build; only disabled providers remain") } - return providers, outcomes, nil + return providers, outcomes } -// buildAIProviderFromRow decodes the settings blob and constructs the -// appropriate [aibridge.Provider] for a single ai_providers row. -// Disabled rows return a Provider stub carrying only Name and -// Disabled: true; settings decode, key loading, and credential checks -// are skipped because the provider will never call upstream. -func buildAIProviderFromRow( - ctx context.Context, - row database.AIProvider, - keys []database.AIProviderKey, - cfg codersdk.AIBridgeConfig, - metrics *aibridge.Metrics, -) (aibridge.Provider, error) { - if !row.Enabled { - return disabledProviderFromRow(row) +// protoToProviderSpec maps a proto [proto.AIProvider] into the database-neutral +// [aiProviderSpec] consumed by [buildProvider]. Keys and Bedrock settings are +// only meaningful for enabled providers; disabled providers carry neither over +// the wire. +func protoToProviderSpec(pp *proto.AIProvider) aiProviderSpec { + spec := aiProviderSpec{ + Type: database.AIProviderType(pp.GetType()), + Name: pp.GetName(), + Enabled: pp.GetEnabled(), + BaseURL: pp.GetBaseUrl(), + Keys: pp.GetKeys(), } + if b := pp.GetBedrock(); b != nil { + bedrock := codersdk.NewAIProviderBedrockSettings( + b.GetRegion(), + b.GetAccessKey(), + b.GetAccessKeySecret(), + b.GetModel(), + b.GetSmallFastModel(), + ) + bedrock.RoleARN = b.GetRoleArn() + spec.Bedrock = ptr.Ref(bedrock) + } + return spec +} - settings, err := db2sdk.AIProviderSettings(row.Settings) - if err != nil { - return nil, xerrors.Errorf("decode settings: %w", err) +// aiProviderSpec is a database-neutral description of a single provider, +// carrying exactly the inputs [buildProvider] needs. The RPC path +// ([protoToProviderSpec]) maps the proto provider into this shape so the +// per-type construction logic stays in one place. +type aiProviderSpec struct { + Type database.AIProviderType + Name string + Enabled bool + BaseURL string + // Keys holds bearer API keys for non-Bedrock providers. + Keys []string + // Bedrock holds Bedrock-specific settings when the provider targets + // AWS Bedrock; nil otherwise. + Bedrock *codersdk.AIProviderBedrockSettings +} + +// buildProvider constructs the appropriate [aibridge.Provider] for a +// single provider spec, independent of where the spec was sourced from. +func buildProvider(ctx context.Context, spec aiProviderSpec, cfg codersdk.AIBridgeConfig, metrics *aibridge.Metrics) (aibridge.Provider, error) { + if !spec.Enabled { + return aibridge.NewDisabledProviderStub(spec.Name, string(spec.Type)), nil } cbCfg := circuitBreakerConfig(cfg) @@ -235,27 +249,27 @@ func buildAIProviderFromRow( // provider because chatd configures them against their // OpenAI-compatible endpoints. Bedrock routes through the Anthropic // provider with a Bedrock discriminator in Settings. - switch row.Type { + switch spec.Type { case database.AIProviderTypeOpenai, database.AIProviderTypeAzure, database.AIProviderTypeGoogle, database.AIProviderTypeOpenaiCompat, database.AIProviderTypeOpenrouter, database.AIProviderTypeVercel: - if len(keys) == 0 && !cfg.AllowBYOK.Value() { - return nil, xerrors.Errorf("%s provider has no api keys configured and BYOK is not enabled", row.Type) + if len(spec.Keys) == 0 && !cfg.AllowBYOK.Value() { + return nil, xerrors.Errorf("%s provider has no api keys configured and BYOK is not enabled", spec.Type) } var pool *keypool.Pool - if len(keys) > 0 { + if len(spec.Keys) > 0 { var err error - pool, err = buildAIProviderKeyPool(row.Name, keys, metrics) + pool, err = buildAIProviderKeyPool(spec.Name, spec.Keys, metrics) if err != nil { - return nil, xerrors.Errorf("%s key pool: %w", row.Type, err) + return nil, xerrors.Errorf("%s key pool: %w", spec.Type, err) } } return aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ - Name: row.Name, - BaseURL: row.BaseUrl, + Name: spec.Name, + BaseURL: spec.BaseURL, KeyPool: pool, APIDumpDir: dumpDir, CircuitBreaker: cbCfg, @@ -263,31 +277,31 @@ func buildAIProviderFromRow( }), nil case database.AIProviderTypeAnthropic, database.AIProviderTypeBedrock: - bedrock := bedrockConfigFromRow(row, settings) - // A row typed 'bedrock' authenticates exclusively via settings; + bedrock := bedrockConfig(spec.BaseURL, spec.Bedrock) + // A spec typed 'bedrock' authenticates exclusively via settings; // without populated Bedrock credentials it cannot make upstream // calls, so refuse rather than falling back to an unsigned // Anthropic client. - if row.Type == database.AIProviderTypeBedrock && bedrock == nil { + if spec.Type == database.AIProviderTypeBedrock && bedrock == nil { return nil, xerrors.New("bedrock provider has no bedrock credentials configured") } // Bedrock-backed Anthropic authenticates via AWS credentials in - // the settings blob, not the api_keys table. A bearer-token - // Anthropic without any key cannot make upstream calls. - if bedrock == nil && len(keys) == 0 && !cfg.AllowBYOK.Value() { + // the settings blob, not bearer keys. A bearer-token Anthropic + // without any key cannot make upstream calls. + if bedrock == nil && len(spec.Keys) == 0 && !cfg.AllowBYOK.Value() { return nil, xerrors.New("anthropic provider has no api keys, no bedrock credentials, and BYOK is not enabled") } var pool *keypool.Pool - if len(keys) > 0 { + if len(spec.Keys) > 0 { var err error - pool, err = buildAIProviderKeyPool(row.Name, keys, metrics) + pool, err = buildAIProviderKeyPool(spec.Name, spec.Keys, metrics) if err != nil { return nil, xerrors.Errorf("anthropic key pool: %w", err) } } return aibridge.NewAnthropicProvider(ctx, aibridge.AnthropicConfig{ - Name: row.Name, - BaseURL: row.BaseUrl, + Name: spec.Name, + BaseURL: spec.BaseURL, KeyPool: pool, APIDumpDir: dumpDir, CircuitBreaker: cbCfg, @@ -298,52 +312,40 @@ func buildAIProviderFromRow( // Copilot is always BYOK; the per-user token is supplied on each // request via the Authorization header, so no keypool is built. return aibridge.NewCopilotProvider(aibridge.CopilotConfig{ - Name: row.Name, - BaseURL: row.BaseUrl, + Name: spec.Name, + BaseURL: spec.BaseURL, APIDumpDir: dumpDir, CircuitBreaker: cbCfg, }), nil default: - return nil, xerrors.Errorf("unsupported provider type: %q", row.Type) + return nil, xerrors.Errorf("unsupported provider type: %q", spec.Type) } } -// disabledProviderFromRow builds a Provider stub for a disabled row. -// Using provider.DisabledStub rather than a concrete provider avoids -// duplicating the row.Type switch and ensures that a new AIProviderType -// value is automatically handled without requiring a matching case here. -func disabledProviderFromRow(row database.AIProvider) (aibridge.Provider, error) { - return aibridge.NewDisabledProviderStub(row.Name, string(row.Type)), nil -} - // buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check // len(keys) > 0 first; keypool.New rejects empty input. -func buildAIProviderKeyPool(providerName string, keys []database.AIProviderKey, metrics *aibridge.Metrics) (*keypool.Pool, error) { - raw := make([]string, 0, len(keys)) - for _, k := range keys { - raw = append(raw, k.APIKey) - } - return keypool.New(providerName, raw, quartz.NewReal(), metrics) +func buildAIProviderKeyPool(providerName string, keys []string, metrics *aibridge.Metrics) (*keypool.Pool, error) { + return keypool.New(providerName, keys, quartz.NewReal(), metrics) } -// bedrockConfigFromRow returns nil when the settings have no Bedrock -// discriminator or when the Bedrock fields are not actually configured. -// The provider row's BaseUrl is the generic upstream endpoint and is -// always non-empty, so it cannot serve as a Bedrock detection signal; -// gate on the settings blob alone via [codersdk.AIProviderBedrockSettings.IsConfigured]. -func bedrockConfigFromRow(row database.AIProvider, settings codersdk.AIProviderSettings) *aibridge.AWSBedrockConfig { - if settings.Bedrock == nil { +// bedrockConfig returns nil when the settings are absent or when the +// Bedrock fields are not actually configured. The provider's BaseURL is +// the generic upstream endpoint and is always non-empty, so it cannot +// serve as a Bedrock detection signal; gate on the settings alone via +// [codersdk.AIProviderBedrockSettings.IsConfigured]. +func bedrockConfig(baseURL string, bedrock *codersdk.AIProviderBedrockSettings) *aibridge.AWSBedrockConfig { + if bedrock == nil { return nil } - bedrockSettings := *settings.Bedrock + bedrockSettings := *bedrock if !bedrockSettings.IsConfigured() { return nil } accessKey := ptr.NilToEmpty(bedrockSettings.AccessKey) accessKeySecret := ptr.NilToEmpty(bedrockSettings.AccessKeySecret) return &aibridge.AWSBedrockConfig{ - BaseURL: row.BaseUrl, + BaseURL: baseURL, Region: bedrockSettings.Region, AccessKey: accessKey, AccessKeySecret: accessKeySecret, diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go index d431b063b7..5c2d8f6d88 100644 --- a/cli/aibridged_internal_test.go +++ b/cli/aibridged_internal_test.go @@ -3,17 +3,22 @@ package cli import ( + "context" "database/sql" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/coderd" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/aibridged" + "github.com/coder/coder/v2/coderd/aibridged/proto" + "github.com/coder/coder/v2/coderd/aibridgedserver" + agplaiseats "github.com/coder/coder/v2/coderd/aiseats" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -24,10 +29,12 @@ import ( // buildFromEnv exercises the same env-config-in/providers-out path that // production uses on boot: SeedAIProvidersFromEnv writes the env-derived -// rows to the database, and BuildProviders reads them back as runtime -// [aibridge.Provider] instances. This keeps the existing TestBuildProviders -// table intact while reflecting the post-refactor flow where the database -// is the single source of truth. +// rows to the database, the server's GetAIProviders handler reads them back +// over the (post-refactor) DB-read path and maps them to proto, and +// BuildProvidersFromProto constructs the runtime [aibridge.Provider] +// instances. This keeps the existing TestBuildProviders table intact while +// reflecting the post-refactor flow where the database is the single source +// of truth and the gateway fetches providers over DRPC. func buildFromEnv(t *testing.T, cfg codersdk.AIBridgeConfig) ([]aibridge.Provider, error) { t.Helper() db, _ := dbtestutil.NewDB(t) @@ -36,10 +43,28 @@ func buildFromEnv(t *testing.T, cfg codersdk.AIBridgeConfig) ([]aibridge.Provide if err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, logger); err != nil { return nil, err } - providers, _, err := BuildProviders(ctx, db, cfg, logger, nil) + providers, _, err := buildFromDB(ctx, t, db, cfg, logger) return providers, err } +// buildFromDB runs the production fetch path against a database: it calls the +// server's GetAIProviders handler (DB read + proto mapping) and then +// BuildProvidersFromProto (proto -> runtime providers), returning the same +// (providers, outcomes) the embedded reloader would observe. +func buildFromDB(ctx context.Context, t *testing.T, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) { + t.Helper() + srv, err := aibridgedserver.NewServer(ctx, db, logger, "/", cfg, nil, nil, agplaiseats.Noop{}) + if err != nil { + return nil, nil, err + } + resp, err := srv.GetAIProviders(ctx, &proto.GetAIProvidersRequest{}) + if err != nil { + return nil, nil, err + } + providers, outcomes := BuildProvidersFromProto(ctx, resp.GetProviders(), cfg, logger, nil) + return providers, outcomes, nil +} + func TestBuildProviders(t *testing.T) { t.Parallel() @@ -243,7 +268,7 @@ func TestBuildProviders(t *testing.T) { Name: aibridge.ProviderAnthropic, BaseUrl: "https://api.anthropic.com/", } - assert.Nil(t, bedrockConfigFromRow(row, codersdk.AIProviderSettings{})) + assert.Nil(t, bedrockConfig(row.BaseUrl, codersdk.AIProviderSettings{}.Bedrock)) }) t.Run("NativeAnthropicCustomBaseURL", func(t *testing.T) { @@ -253,7 +278,7 @@ func TestBuildProviders(t *testing.T) { Name: "anthropic-proxy", BaseUrl: "https://internal-proxy.example.com/anthropic/", } - assert.Nil(t, bedrockConfigFromRow(row, codersdk.AIProviderSettings{})) + assert.Nil(t, bedrockConfig(row.BaseUrl, codersdk.AIProviderSettings{}.Bedrock)) }) t.Run("BedrockSettingsPresent", func(t *testing.T) { @@ -278,7 +303,7 @@ func TestBuildProviders(t *testing.T) { RoleARN: roleARN, }, } - got := bedrockConfigFromRow(row, settings) + got := bedrockConfig(row.BaseUrl, settings.Bedrock) require.NotNil(t, got) assert.Equal(t, row.BaseUrl, got.BaseURL) assert.Equal(t, "us-west-2", got.Region) @@ -302,7 +327,7 @@ func TestBuildProviders(t *testing.T) { settings := codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{}, } - assert.Nil(t, bedrockConfigFromRow(row, settings)) + assert.Nil(t, bedrockConfig(row.BaseUrl, settings.Bedrock)) }) } @@ -328,13 +353,14 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { Settings: sql.NullString{String: "not-json", Valid: true}, }) - providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger, nil) + // A row whose settings blob cannot be decoded is dropped server-side + // in GetAIProviders, so it never reaches the client: no provider and + // no outcome. This keeps one corrupt row from breaking the fetch (and + // thus provider configuration) for every gateway. + providers, outcomes, err := buildFromDB(ctx, t, db, codersdk.AIBridgeConfig{}, logger) require.NoError(t, err) assert.Empty(t, providers) - require.Len(t, outcomes, 1) - assert.Equal(t, "anthropic-broken", outcomes[0].Name) - assert.Equal(t, aibridged.ProviderStatusError, outcomes[0].Status) - assert.Error(t, outcomes[0].Err) + assert.Empty(t, outcomes) }) t.Run("EnabledButNoKeys", func(t *testing.T) { @@ -352,7 +378,7 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { BaseUrl: "https://example.openai.azure.com/", }) - providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger, nil) + providers, outcomes, err := buildFromDB(ctx, t, db, codersdk.AIBridgeConfig{}, logger) require.NoError(t, err) assert.Empty(t, providers) require.Len(t, outcomes, 1) @@ -365,11 +391,13 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + // An enabled provider with no keys (and BYOK disabled) fails to build + // on the client side, yielding a ProviderStatusError outcome. It must + // not prevent the good provider from being built. dbgen.AIProvider(t, db, database.AIProvider{ - Type: database.AIProviderTypeAnthropic, - Name: "anthropic-broken", - BaseUrl: "https://api.anthropic.com/", - Settings: sql.NullString{String: "{not valid json", Valid: true}, + Type: database.AIProviderTypeAzure, + Name: "azure-broken", + BaseUrl: "https://example.openai.azure.com/", }) good := dbgen.AIProvider(t, db, database.AIProvider{ Type: database.AIProviderTypeOpenai, @@ -381,7 +409,7 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { APIKey: "sk-good", }) - providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger, nil) + providers, outcomes, err := buildFromDB(ctx, t, db, codersdk.AIBridgeConfig{}, logger) require.NoError(t, err) require.Len(t, providers, 1) assert.Equal(t, "openai-good", providers[0].Name()) @@ -390,7 +418,7 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { for _, o := range outcomes { byName[o.Name] = o } - assert.Equal(t, aibridged.ProviderStatusError, byName["anthropic-broken"].Status) + assert.Equal(t, aibridged.ProviderStatusError, byName["azure-broken"].Status) assert.Equal(t, aibridged.ProviderStatusEnabled, byName["openai-good"].Status) }) @@ -439,7 +467,7 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { p.Enabled = false }) - providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger, nil) + providers, outcomes, err := buildFromDB(ctx, t, db, codersdk.AIBridgeConfig{}, logger) require.NoError(t, err) require.Len(t, providers, 1, "disabled providers stay in the snapshot so the bridge can serve a 503 sentinel") assert.Equal(t, tc.row.Name, providers[0].Name()) diff --git a/cli/server.go b/cli/server.go index 8f636cec35..505321d852 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1134,12 +1134,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // https://linear.app/codercom/issue/AIGOV-447/remove-legacy-ai-gateway-metric-aliases aibridgeReg := prometheusmetrics.NewMetricAliasRegisterer(coderAPI.PrometheusRegistry, "coder_ai_gateway_", "coder_aibridged_") aibridgeMetrics := aibridge.NewMetrics(aibridgeReg) - aibridgeProviders, _, err := BuildProviders(aibridgeInitCtx, options.Database, vals.AI.BridgeConfig, logger.Named("aibridge.providers"), aibridgeMetrics) - if err != nil { - return xerrors.Errorf("build AI providers: %w", err) - } var unsubscribeProviderReload func() - aibridgeDaemon, unsubscribeProviderReload, err = newAIBridgeDaemon(coderAPI, aibridgeProviders, vals.AI.BridgeConfig, aibridgeReg, aibridgeMetrics) + aibridgeDaemon, unsubscribeProviderReload, err = newAIBridgeDaemon(coderAPI, vals.AI.BridgeConfig, aibridgeReg, aibridgeMetrics) if err != nil { return xerrors.Errorf("create aibridged: %w", err) } diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index cb08ec530b..7a3732cef7 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -2,8 +2,6 @@ package cli import ( "context" - "database/sql" - "encoding/json" "fmt" "testing" @@ -13,8 +11,8 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/coderd/aibridged/proto" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/serpent" @@ -628,21 +626,21 @@ func TestWarnIfAIProvidersConfiguredFromEnv(t *testing.T) { }) } -func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { +func TestBuildProviderFromProtoSetsAPIDumpDir(t *testing.T) { t.Parallel() const dumpDir = "/tmp/coder-aibridge-dumps" tests := []struct { name string - row database.AIProvider + provider *proto.AIProvider expectedType string }{ { name: "OpenAI", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeOpenai, + Type: string(database.AIProviderTypeOpenai), Name: "openai", BaseUrl: "https://api.openai.com/", }, @@ -650,9 +648,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "Anthropic", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeAnthropic, + Type: string(database.AIProviderTypeAnthropic), Name: "anthropic", BaseUrl: "https://api.anthropic.com/", }, @@ -660,9 +658,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "Copilot", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeCopilot, + Type: string(database.AIProviderTypeCopilot), Name: "copilot", BaseUrl: "https://api.githubcopilot.com/", }, @@ -670,9 +668,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "Azure", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeAzure, + Type: string(database.AIProviderTypeAzure), Name: "azure", BaseUrl: "https://example.openai.azure.com/", }, @@ -680,9 +678,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "Google", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeGoogle, + Type: string(database.AIProviderTypeGoogle), Name: "google", BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", }, @@ -690,9 +688,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "OpenAICompat", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeOpenaiCompat, + Type: string(database.AIProviderTypeOpenaiCompat), Name: "openai-compat", BaseUrl: "https://compat.example.com/v1/", }, @@ -700,9 +698,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "OpenRouter", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeOpenrouter, + Type: string(database.AIProviderTypeOpenrouter), Name: "openrouter", BaseUrl: "https://openrouter.ai/api/v1/", }, @@ -710,9 +708,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "Vercel", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeVercel, + Type: string(database.AIProviderTypeVercel), Name: "vercel", BaseUrl: "https://api.v0.dev/v1/", }, @@ -720,18 +718,16 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { }, { name: "Bedrock", - row: database.AIProvider{ + provider: &proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeBedrock, + Type: string(database.AIProviderTypeBedrock), Name: "bedrock", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", - Settings: mustMarshalSettings(codersdk.AIProviderSettings{ - Bedrock: &codersdk.AIProviderBedrockSettings{ - Region: "us-east-1", - AccessKey: ptr.Ref("AKID"), - AccessKeySecret: ptr.Ref("secret"), - }, - }), + Bedrock: &proto.AIProviderKindBedrock{ + Region: "us-east-1", + AccessKey: "AKID", + AccessKeySecret: "secret", + }, }, expectedType: aibridge.ProviderAnthropic, }, @@ -741,7 +737,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - provider, err := buildAIProviderFromRow(t.Context(), tt.row, nil, codersdk.AIBridgeConfig{ + provider, err := buildProvider(t.Context(), protoToProviderSpec(tt.provider), codersdk.AIBridgeConfig{ AllowBYOK: serpent.Bool(true), APIDumpDir: serpent.String(dumpDir), }, nil) @@ -752,29 +748,21 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { } } -func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) { +func TestBuildProviderFromProtoBedrockWithoutSettings(t *testing.T) { t.Parallel() - _, err := buildAIProviderFromRow(t.Context(), database.AIProvider{ + _, err := buildProvider(t.Context(), protoToProviderSpec(&proto.AIProvider{ Enabled: true, - Type: database.AIProviderTypeBedrock, + Type: string(database.AIProviderTypeBedrock), Name: "bedrock-no-settings", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", - }, nil, codersdk.AIBridgeConfig{ + }), codersdk.AIBridgeConfig{ AllowBYOK: serpent.Bool(true), }, nil) require.Error(t, err) assert.Contains(t, err.Error(), "bedrock provider has no bedrock credentials configured") } -func mustMarshalSettings(s codersdk.AIProviderSettings) sql.NullString { - data, err := json.Marshal(s) - if err != nil { - panic(err) - } - return sql.NullString{String: string(data), Valid: true} -} - func assertFieldValue(t *testing.T, fields slog.Map, name string, expected interface{}) { t.Helper() for _, f := range fields { diff --git a/coderd/aibridged.go b/coderd/aibridged.go index a088c9a041..add55e2097 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -103,9 +103,10 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai }() return &aibridged.Client{ - Conn: clientSession, - DRPCRecorderClient: aibridgedproto.NewDRPCRecorderClient(clientSession), - DRPCMCPConfiguratorClient: aibridgedproto.NewDRPCMCPConfiguratorClient(clientSession), - DRPCAuthorizerClient: aibridgedproto.NewDRPCAuthorizerClient(clientSession), + Conn: clientSession, + DRPCRecorderClient: aibridgedproto.NewDRPCRecorderClient(clientSession), + DRPCMCPConfiguratorClient: aibridgedproto.NewDRPCMCPConfiguratorClient(clientSession), + DRPCAuthorizerClient: aibridgedproto.NewDRPCAuthorizerClient(clientSession), + DRPCProviderConfiguratorClient: aibridgedproto.NewDRPCProviderConfiguratorClient(clientSession), }, nil } diff --git a/coderd/aibridged/aibridged.go b/coderd/aibridged/aibridged.go index b001c4f942..d70a507847 100644 --- a/coderd/aibridged/aibridged.go +++ b/coderd/aibridged/aibridged.go @@ -108,8 +108,10 @@ connectLoop: continue } - // TODO: log this with INFO level when we implement external aibridge daemons. - logConnect(s.lifecycleCtx, "successfully connected to coderd") + // Logged at info so operators of standalone (external) gateways + // can see initial connection and reconnection after a dial + // failure (paired with the warning logged above). + s.logger.Info(s.lifecycleCtx, "successfully connected to coderd") retrier.Reset() s.initConnectionOnce.Do(func() { close(s.initConnectionCh) diff --git a/coderd/aibridged/aibridgedmock/clientmock.go b/coderd/aibridged/aibridgedmock/clientmock.go index f353e10654..2ae8d283e2 100644 --- a/coderd/aibridged/aibridgedmock/clientmock.go +++ b/coderd/aibridged/aibridgedmock/clientmock.go @@ -56,6 +56,21 @@ func (mr *MockDRPCClientMockRecorder) DRPCConn() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DRPCConn", reflect.TypeOf((*MockDRPCClient)(nil).DRPCConn)) } +// GetAIProviders mocks base method. +func (m *MockDRPCClient) GetAIProviders(ctx context.Context, in *proto.GetAIProvidersRequest) (*proto.GetAIProvidersResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIProviders", ctx, in) + ret0, _ := ret[0].(*proto.GetAIProvidersResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIProviders indicates an expected call of GetAIProviders. +func (mr *MockDRPCClientMockRecorder) GetAIProviders(ctx, in any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviders", reflect.TypeOf((*MockDRPCClient)(nil).GetAIProviders), ctx, in) +} + // GetMCPServerAccessTokensBatch mocks base method. func (m *MockDRPCClient) GetMCPServerAccessTokensBatch(ctx context.Context, in *proto.GetMCPServerAccessTokensBatchRequest) (*proto.GetMCPServerAccessTokensBatchResponse, error) { m.ctrl.T.Helper() diff --git a/coderd/aibridged/client.go b/coderd/aibridged/client.go index ffbb45b94e..2fcd373396 100644 --- a/coderd/aibridged/client.go +++ b/coderd/aibridged/client.go @@ -17,6 +17,7 @@ type DRPCClient interface { proto.DRPCRecorderClient proto.DRPCMCPConfiguratorClient proto.DRPCAuthorizerClient + proto.DRPCProviderConfiguratorClient } var _ DRPCClient = &Client{} @@ -25,6 +26,7 @@ type Client struct { proto.DRPCRecorderClient proto.DRPCMCPConfiguratorClient proto.DRPCAuthorizerClient + proto.DRPCProviderConfiguratorClient Conn drpc.Conn } diff --git a/coderd/aibridged/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index 31a9b3fe4c..f503aaa8fb 100644 --- a/coderd/aibridged/proto/aibridged.pb.go +++ b/coderd/aibridged/proto/aibridged.pb.go @@ -1268,6 +1268,268 @@ func (x *IsAuthorizedResponse) GetUsername() string { return "" } +type GetAIProvidersRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetAIProvidersRequest) Reset() { + *x = GetAIProvidersRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetAIProvidersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAIProvidersRequest) ProtoMessage() {} + +func (x *GetAIProvidersRequest) ProtoReflect() protoreflect.Message { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAIProvidersRequest.ProtoReflect.Descriptor instead. +func (*GetAIProvidersRequest) Descriptor() ([]byte, []int) { + return file_coderd_aibridged_proto_aibridged_proto_rawDescGZIP(), []int{19} +} + +type GetAIProvidersResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Providers []*AIProvider `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` +} + +func (x *GetAIProvidersResponse) Reset() { + *x = GetAIProvidersResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetAIProvidersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAIProvidersResponse) ProtoMessage() {} + +func (x *GetAIProvidersResponse) ProtoReflect() protoreflect.Message { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAIProvidersResponse.ProtoReflect.Descriptor instead. +func (*GetAIProvidersResponse) Descriptor() ([]byte, []int) { + return file_coderd_aibridged_proto_aibridged_proto_rawDescGZIP(), []int{20} +} + +func (x *GetAIProvidersResponse) GetProviders() []*AIProvider { + if x != nil { + return x.Providers + } + return nil +} + +type AIProvider struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Enabled bool `protobuf:"varint,3,opt,name=enabled,proto3" json:"enabled,omitempty"` + BaseUrl string `protobuf:"bytes,4,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + // keys carries bearer API keys, populated only for enabled providers. + Keys []string `protobuf:"bytes,5,rep,name=keys,proto3" json:"keys,omitempty"` + // bedrock is populated when the provider's settings include Bedrock + // credentials (regardless of provider type). + Bedrock *AIProviderKindBedrock `protobuf:"bytes,6,opt,name=bedrock,proto3" json:"bedrock,omitempty"` +} + +func (x *AIProvider) Reset() { + *x = AIProvider{} + if protoimpl.UnsafeEnabled { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AIProvider) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AIProvider) ProtoMessage() {} + +func (x *AIProvider) ProtoReflect() protoreflect.Message { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[21] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AIProvider.ProtoReflect.Descriptor instead. +func (*AIProvider) Descriptor() ([]byte, []int) { + return file_coderd_aibridged_proto_aibridged_proto_rawDescGZIP(), []int{21} +} + +func (x *AIProvider) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AIProvider) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *AIProvider) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *AIProvider) GetBaseUrl() string { + if x != nil { + return x.BaseUrl + } + return "" +} + +func (x *AIProvider) GetKeys() []string { + if x != nil { + return x.Keys + } + return nil +} + +func (x *AIProvider) GetBedrock() *AIProviderKindBedrock { + if x != nil { + return x.Bedrock + } + return nil +} + +type AIProviderKindBedrock struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Region string `protobuf:"bytes,1,opt,name=region,proto3" json:"region,omitempty"` + AccessKey string `protobuf:"bytes,2,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"` + AccessKeySecret string `protobuf:"bytes,3,opt,name=access_key_secret,json=accessKeySecret,proto3" json:"access_key_secret,omitempty"` + Model string `protobuf:"bytes,4,opt,name=model,proto3" json:"model,omitempty"` + SmallFastModel string `protobuf:"bytes,5,opt,name=small_fast_model,json=smallFastModel,proto3" json:"small_fast_model,omitempty"` + RoleArn string `protobuf:"bytes,6,opt,name=role_arn,json=roleArn,proto3" json:"role_arn,omitempty"` +} + +func (x *AIProviderKindBedrock) Reset() { + *x = AIProviderKindBedrock{} + if protoimpl.UnsafeEnabled { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AIProviderKindBedrock) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AIProviderKindBedrock) ProtoMessage() {} + +func (x *AIProviderKindBedrock) ProtoReflect() protoreflect.Message { + mi := &file_coderd_aibridged_proto_aibridged_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AIProviderKindBedrock.ProtoReflect.Descriptor instead. +func (*AIProviderKindBedrock) Descriptor() ([]byte, []int) { + return file_coderd_aibridged_proto_aibridged_proto_rawDescGZIP(), []int{22} +} + +func (x *AIProviderKindBedrock) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *AIProviderKindBedrock) GetAccessKey() string { + if x != nil { + return x.AccessKey + } + return "" +} + +func (x *AIProviderKindBedrock) GetAccessKeySecret() string { + if x != nil { + return x.AccessKeySecret + } + return "" +} + +func (x *AIProviderKindBedrock) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *AIProviderKindBedrock) GetSmallFastModel() string { + if x != nil { + return x.SmallFastModel + } + return "" +} + +func (x *AIProviderKindBedrock) GetRoleArn() string { + if x != nil { + return x.RoleArn + } + return "" +} + var File_coderd_aibridged_proto_aibridged_proto protoreflect.FileDescriptor var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ @@ -1523,65 +1785,103 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, - 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, - 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, - 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, - 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, - 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, - 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, - 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, - 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, - 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, - 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, - 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, - 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, - 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x22, 0x17, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x49, 0x0a, 0x16, 0x47, 0x65, 0x74, + 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, + 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x73, 0x22, 0xb5, 0x01, 0x0a, 0x0a, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x65, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x75, 0x72, + 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x61, 0x73, 0x65, 0x55, 0x72, 0x6c, + 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, + 0x6b, 0x65, 0x79, 0x73, 0x12, 0x36, 0x0a, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x49, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4b, 0x69, 0x6e, 0x64, 0x42, 0x65, 0x64, 0x72, + 0x6f, 0x63, 0x6b, 0x52, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x22, 0xd5, 0x01, 0x0a, + 0x15, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4b, 0x69, 0x6e, 0x64, 0x42, + 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, 0x1d, + 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x2a, 0x0a, + 0x11, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x73, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x4b, 0x65, 0x79, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, + 0x28, 0x0a, 0x10, 0x73, 0x6d, 0x61, 0x6c, 0x6c, 0x5f, 0x66, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x6d, 0x61, 0x6c, 0x6c, + 0x46, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x6f, 0x6c, + 0x65, 0x5f, 0x61, 0x72, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x6f, 0x6c, + 0x65, 0x41, 0x72, 0x6e, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, + 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, + 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, + 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, + 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, + 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, + 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, + 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, + 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, + 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, + 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, + 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, + 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, + 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x65, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x4d, 0x0a, + 0x0e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, + 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, + 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1596,7 +1896,7 @@ func file_coderd_aibridged_proto_aibridged_proto_rawDescGZIP() []byte { return file_coderd_aibridged_proto_aibridged_proto_rawDescData } -var file_coderd_aibridged_proto_aibridged_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_coderd_aibridged_proto_aibridged_proto_msgTypes = make([]protoimpl.MessageInfo, 30) var file_coderd_aibridged_proto_aibridged_proto_goTypes = []interface{}{ (*RecordInterceptionRequest)(nil), // 0: proto.RecordInterceptionRequest (*RecordInterceptionResponse)(nil), // 1: proto.RecordInterceptionResponse @@ -1617,60 +1917,68 @@ var file_coderd_aibridged_proto_aibridged_proto_goTypes = []interface{}{ (*GetMCPServerAccessTokensBatchResponse)(nil), // 16: proto.GetMCPServerAccessTokensBatchResponse (*IsAuthorizedRequest)(nil), // 17: proto.IsAuthorizedRequest (*IsAuthorizedResponse)(nil), // 18: proto.IsAuthorizedResponse - nil, // 19: proto.RecordInterceptionRequest.MetadataEntry - nil, // 20: proto.RecordTokenUsageRequest.MetadataEntry - nil, // 21: proto.RecordPromptUsageRequest.MetadataEntry - nil, // 22: proto.RecordToolUsageRequest.MetadataEntry - nil, // 23: proto.RecordModelThoughtRequest.MetadataEntry - nil, // 24: proto.GetMCPServerAccessTokensBatchResponse.AccessTokensEntry - nil, // 25: proto.GetMCPServerAccessTokensBatchResponse.ErrorsEntry - (*timestamppb.Timestamp)(nil), // 26: google.protobuf.Timestamp - (*anypb.Any)(nil), // 27: google.protobuf.Any + (*GetAIProvidersRequest)(nil), // 19: proto.GetAIProvidersRequest + (*GetAIProvidersResponse)(nil), // 20: proto.GetAIProvidersResponse + (*AIProvider)(nil), // 21: proto.AIProvider + (*AIProviderKindBedrock)(nil), // 22: proto.AIProviderKindBedrock + nil, // 23: proto.RecordInterceptionRequest.MetadataEntry + nil, // 24: proto.RecordTokenUsageRequest.MetadataEntry + nil, // 25: proto.RecordPromptUsageRequest.MetadataEntry + nil, // 26: proto.RecordToolUsageRequest.MetadataEntry + nil, // 27: proto.RecordModelThoughtRequest.MetadataEntry + nil, // 28: proto.GetMCPServerAccessTokensBatchResponse.AccessTokensEntry + nil, // 29: proto.GetMCPServerAccessTokensBatchResponse.ErrorsEntry + (*timestamppb.Timestamp)(nil), // 30: google.protobuf.Timestamp + (*anypb.Any)(nil), // 31: google.protobuf.Any } var file_coderd_aibridged_proto_aibridged_proto_depIdxs = []int32{ - 19, // 0: proto.RecordInterceptionRequest.metadata:type_name -> proto.RecordInterceptionRequest.MetadataEntry - 26, // 1: proto.RecordInterceptionRequest.started_at:type_name -> google.protobuf.Timestamp - 26, // 2: proto.RecordInterceptionEndedRequest.ended_at:type_name -> google.protobuf.Timestamp - 20, // 3: proto.RecordTokenUsageRequest.metadata:type_name -> proto.RecordTokenUsageRequest.MetadataEntry - 26, // 4: proto.RecordTokenUsageRequest.created_at:type_name -> google.protobuf.Timestamp - 21, // 5: proto.RecordPromptUsageRequest.metadata:type_name -> proto.RecordPromptUsageRequest.MetadataEntry - 26, // 6: proto.RecordPromptUsageRequest.created_at:type_name -> google.protobuf.Timestamp - 22, // 7: proto.RecordToolUsageRequest.metadata:type_name -> proto.RecordToolUsageRequest.MetadataEntry - 26, // 8: proto.RecordToolUsageRequest.created_at:type_name -> google.protobuf.Timestamp - 23, // 9: proto.RecordModelThoughtRequest.metadata:type_name -> proto.RecordModelThoughtRequest.MetadataEntry - 26, // 10: proto.RecordModelThoughtRequest.created_at:type_name -> google.protobuf.Timestamp + 23, // 0: proto.RecordInterceptionRequest.metadata:type_name -> proto.RecordInterceptionRequest.MetadataEntry + 30, // 1: proto.RecordInterceptionRequest.started_at:type_name -> google.protobuf.Timestamp + 30, // 2: proto.RecordInterceptionEndedRequest.ended_at:type_name -> google.protobuf.Timestamp + 24, // 3: proto.RecordTokenUsageRequest.metadata:type_name -> proto.RecordTokenUsageRequest.MetadataEntry + 30, // 4: proto.RecordTokenUsageRequest.created_at:type_name -> google.protobuf.Timestamp + 25, // 5: proto.RecordPromptUsageRequest.metadata:type_name -> proto.RecordPromptUsageRequest.MetadataEntry + 30, // 6: proto.RecordPromptUsageRequest.created_at:type_name -> google.protobuf.Timestamp + 26, // 7: proto.RecordToolUsageRequest.metadata:type_name -> proto.RecordToolUsageRequest.MetadataEntry + 30, // 8: proto.RecordToolUsageRequest.created_at:type_name -> google.protobuf.Timestamp + 27, // 9: proto.RecordModelThoughtRequest.metadata:type_name -> proto.RecordModelThoughtRequest.MetadataEntry + 30, // 10: proto.RecordModelThoughtRequest.created_at:type_name -> google.protobuf.Timestamp 14, // 11: proto.GetMCPServerConfigsResponse.coder_mcp_config:type_name -> proto.MCPServerConfig 14, // 12: proto.GetMCPServerConfigsResponse.external_auth_mcp_configs:type_name -> proto.MCPServerConfig - 24, // 13: proto.GetMCPServerAccessTokensBatchResponse.access_tokens:type_name -> proto.GetMCPServerAccessTokensBatchResponse.AccessTokensEntry - 25, // 14: proto.GetMCPServerAccessTokensBatchResponse.errors:type_name -> proto.GetMCPServerAccessTokensBatchResponse.ErrorsEntry - 27, // 15: proto.RecordInterceptionRequest.MetadataEntry.value:type_name -> google.protobuf.Any - 27, // 16: proto.RecordTokenUsageRequest.MetadataEntry.value:type_name -> google.protobuf.Any - 27, // 17: proto.RecordPromptUsageRequest.MetadataEntry.value:type_name -> google.protobuf.Any - 27, // 18: proto.RecordToolUsageRequest.MetadataEntry.value:type_name -> google.protobuf.Any - 27, // 19: proto.RecordModelThoughtRequest.MetadataEntry.value:type_name -> google.protobuf.Any - 0, // 20: proto.Recorder.RecordInterception:input_type -> proto.RecordInterceptionRequest - 2, // 21: proto.Recorder.RecordInterceptionEnded:input_type -> proto.RecordInterceptionEndedRequest - 4, // 22: proto.Recorder.RecordTokenUsage:input_type -> proto.RecordTokenUsageRequest - 6, // 23: proto.Recorder.RecordPromptUsage:input_type -> proto.RecordPromptUsageRequest - 8, // 24: proto.Recorder.RecordToolUsage:input_type -> proto.RecordToolUsageRequest - 10, // 25: proto.Recorder.RecordModelThought:input_type -> proto.RecordModelThoughtRequest - 12, // 26: proto.MCPConfigurator.GetMCPServerConfigs:input_type -> proto.GetMCPServerConfigsRequest - 15, // 27: proto.MCPConfigurator.GetMCPServerAccessTokensBatch:input_type -> proto.GetMCPServerAccessTokensBatchRequest - 17, // 28: proto.Authorizer.IsAuthorized:input_type -> proto.IsAuthorizedRequest - 1, // 29: proto.Recorder.RecordInterception:output_type -> proto.RecordInterceptionResponse - 3, // 30: proto.Recorder.RecordInterceptionEnded:output_type -> proto.RecordInterceptionEndedResponse - 5, // 31: proto.Recorder.RecordTokenUsage:output_type -> proto.RecordTokenUsageResponse - 7, // 32: proto.Recorder.RecordPromptUsage:output_type -> proto.RecordPromptUsageResponse - 9, // 33: proto.Recorder.RecordToolUsage:output_type -> proto.RecordToolUsageResponse - 11, // 34: proto.Recorder.RecordModelThought:output_type -> proto.RecordModelThoughtResponse - 13, // 35: proto.MCPConfigurator.GetMCPServerConfigs:output_type -> proto.GetMCPServerConfigsResponse - 16, // 36: proto.MCPConfigurator.GetMCPServerAccessTokensBatch:output_type -> proto.GetMCPServerAccessTokensBatchResponse - 18, // 37: proto.Authorizer.IsAuthorized:output_type -> proto.IsAuthorizedResponse - 29, // [29:38] is the sub-list for method output_type - 20, // [20:29] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name + 28, // 13: proto.GetMCPServerAccessTokensBatchResponse.access_tokens:type_name -> proto.GetMCPServerAccessTokensBatchResponse.AccessTokensEntry + 29, // 14: proto.GetMCPServerAccessTokensBatchResponse.errors:type_name -> proto.GetMCPServerAccessTokensBatchResponse.ErrorsEntry + 21, // 15: proto.GetAIProvidersResponse.providers:type_name -> proto.AIProvider + 22, // 16: proto.AIProvider.bedrock:type_name -> proto.AIProviderKindBedrock + 31, // 17: proto.RecordInterceptionRequest.MetadataEntry.value:type_name -> google.protobuf.Any + 31, // 18: proto.RecordTokenUsageRequest.MetadataEntry.value:type_name -> google.protobuf.Any + 31, // 19: proto.RecordPromptUsageRequest.MetadataEntry.value:type_name -> google.protobuf.Any + 31, // 20: proto.RecordToolUsageRequest.MetadataEntry.value:type_name -> google.protobuf.Any + 31, // 21: proto.RecordModelThoughtRequest.MetadataEntry.value:type_name -> google.protobuf.Any + 0, // 22: proto.Recorder.RecordInterception:input_type -> proto.RecordInterceptionRequest + 2, // 23: proto.Recorder.RecordInterceptionEnded:input_type -> proto.RecordInterceptionEndedRequest + 4, // 24: proto.Recorder.RecordTokenUsage:input_type -> proto.RecordTokenUsageRequest + 6, // 25: proto.Recorder.RecordPromptUsage:input_type -> proto.RecordPromptUsageRequest + 8, // 26: proto.Recorder.RecordToolUsage:input_type -> proto.RecordToolUsageRequest + 10, // 27: proto.Recorder.RecordModelThought:input_type -> proto.RecordModelThoughtRequest + 12, // 28: proto.MCPConfigurator.GetMCPServerConfigs:input_type -> proto.GetMCPServerConfigsRequest + 15, // 29: proto.MCPConfigurator.GetMCPServerAccessTokensBatch:input_type -> proto.GetMCPServerAccessTokensBatchRequest + 17, // 30: proto.Authorizer.IsAuthorized:input_type -> proto.IsAuthorizedRequest + 19, // 31: proto.ProviderConfigurator.GetAIProviders:input_type -> proto.GetAIProvidersRequest + 1, // 32: proto.Recorder.RecordInterception:output_type -> proto.RecordInterceptionResponse + 3, // 33: proto.Recorder.RecordInterceptionEnded:output_type -> proto.RecordInterceptionEndedResponse + 5, // 34: proto.Recorder.RecordTokenUsage:output_type -> proto.RecordTokenUsageResponse + 7, // 35: proto.Recorder.RecordPromptUsage:output_type -> proto.RecordPromptUsageResponse + 9, // 36: proto.Recorder.RecordToolUsage:output_type -> proto.RecordToolUsageResponse + 11, // 37: proto.Recorder.RecordModelThought:output_type -> proto.RecordModelThoughtResponse + 13, // 38: proto.MCPConfigurator.GetMCPServerConfigs:output_type -> proto.GetMCPServerConfigsResponse + 16, // 39: proto.MCPConfigurator.GetMCPServerAccessTokensBatch:output_type -> proto.GetMCPServerAccessTokensBatchResponse + 18, // 40: proto.Authorizer.IsAuthorized:output_type -> proto.IsAuthorizedResponse + 20, // 41: proto.ProviderConfigurator.GetAIProviders:output_type -> proto.GetAIProvidersResponse + 32, // [32:42] is the sub-list for method output_type + 22, // [22:32] is the sub-list for method input_type + 22, // [22:22] is the sub-list for extension type_name + 22, // [22:22] is the sub-list for extension extendee + 0, // [0:22] is the sub-list for field type_name } func init() { file_coderd_aibridged_proto_aibridged_proto_init() } @@ -1907,6 +2215,54 @@ func file_coderd_aibridged_proto_aibridged_proto_init() { return nil } } + file_coderd_aibridged_proto_aibridged_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetAIProvidersRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_coderd_aibridged_proto_aibridged_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetAIProvidersResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_coderd_aibridged_proto_aibridged_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AIProvider); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_coderd_aibridged_proto_aibridged_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AIProviderKindBedrock); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } file_coderd_aibridged_proto_aibridged_proto_msgTypes[0].OneofWrappers = []interface{}{} file_coderd_aibridged_proto_aibridged_proto_msgTypes[8].OneofWrappers = []interface{}{} @@ -1916,9 +2272,9 @@ func file_coderd_aibridged_proto_aibridged_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_coderd_aibridged_proto_aibridged_proto_rawDesc, NumEnums: 0, - NumMessages: 26, + NumMessages: 30, NumExtensions: 0, - NumServices: 3, + NumServices: 4, }, GoTypes: file_coderd_aibridged_proto_aibridged_proto_goTypes, DependencyIndexes: file_coderd_aibridged_proto_aibridged_proto_depIdxs, diff --git a/coderd/aibridged/proto/aibridged.proto b/coderd/aibridged/proto/aibridged.proto index b1a98b5929..1d22ccdaa8 100644 --- a/coderd/aibridged/proto/aibridged.proto +++ b/coderd/aibridged/proto/aibridged.proto @@ -36,6 +36,14 @@ service Authorizer { rpc IsAuthorized(IsAuthorizedRequest) returns (IsAuthorizedResponse); } +// ProviderConfigurator serves AI provider configuration to embedded and +// standalone AI Gateway daemons. The database is the single source of truth. +service ProviderConfigurator { + // GetAIProviders returns the full provider set (enabled and disabled). + // It synchronizes with provider seeding so the response is never raced. + rpc GetAIProviders(GetAIProvidersRequest) returns (GetAIProvidersResponse); +} + message RecordInterceptionRequest { string id = 1; // UUID. string initiator_id = 2; // UUID. @@ -159,3 +167,30 @@ message IsAuthorizedResponse { string api_key_id = 2; string username = 3; } + +message GetAIProvidersRequest {} + +message GetAIProvidersResponse { + repeated AIProvider providers = 1; +} + +message AIProvider { + string name = 1; + string type = 2; + bool enabled = 3; + string base_url = 4; + // keys carries bearer API keys, populated only for enabled providers. + repeated string keys = 5; + // bedrock is populated when the provider's settings include Bedrock + // credentials (regardless of provider type). + AIProviderKindBedrock bedrock = 6; +} + +message AIProviderKindBedrock { + string region = 1; + string access_key = 2; + string access_key_secret = 3; + string model = 4; + string small_fast_model = 5; + string role_arn = 6; +} diff --git a/coderd/aibridged/proto/aibridged_drpc.pb.go b/coderd/aibridged/proto/aibridged_drpc.pb.go index 89759c213f..5939b888bb 100644 --- a/coderd/aibridged/proto/aibridged_drpc.pb.go +++ b/coderd/aibridged/proto/aibridged_drpc.pb.go @@ -499,3 +499,78 @@ func (x *drpcAuthorizer_IsAuthorizedStream) SendAndClose(m *IsAuthorizedResponse } return x.CloseSend() } + +type DRPCProviderConfiguratorClient interface { + DRPCConn() drpc.Conn + + GetAIProviders(ctx context.Context, in *GetAIProvidersRequest) (*GetAIProvidersResponse, error) +} + +type drpcProviderConfiguratorClient struct { + cc drpc.Conn +} + +func NewDRPCProviderConfiguratorClient(cc drpc.Conn) DRPCProviderConfiguratorClient { + return &drpcProviderConfiguratorClient{cc} +} + +func (c *drpcProviderConfiguratorClient) DRPCConn() drpc.Conn { return c.cc } + +func (c *drpcProviderConfiguratorClient) GetAIProviders(ctx context.Context, in *GetAIProvidersRequest) (*GetAIProvidersResponse, error) { + out := new(GetAIProvidersResponse) + err := c.cc.Invoke(ctx, "/proto.ProviderConfigurator/GetAIProviders", drpcEncoding_File_coderd_aibridged_proto_aibridged_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +type DRPCProviderConfiguratorServer interface { + GetAIProviders(context.Context, *GetAIProvidersRequest) (*GetAIProvidersResponse, error) +} + +type DRPCProviderConfiguratorUnimplementedServer struct{} + +func (s *DRPCProviderConfiguratorUnimplementedServer) GetAIProviders(context.Context, *GetAIProvidersRequest) (*GetAIProvidersResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +type DRPCProviderConfiguratorDescription struct{} + +func (DRPCProviderConfiguratorDescription) NumMethods() int { return 1 } + +func (DRPCProviderConfiguratorDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) { + switch n { + case 0: + return "/proto.ProviderConfigurator/GetAIProviders", drpcEncoding_File_coderd_aibridged_proto_aibridged_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCProviderConfiguratorServer). + GetAIProviders( + ctx, + in1.(*GetAIProvidersRequest), + ) + }, DRPCProviderConfiguratorServer.GetAIProviders, true + default: + return "", nil, nil, nil, false + } +} + +func DRPCRegisterProviderConfigurator(mux drpc.Mux, impl DRPCProviderConfiguratorServer) error { + return mux.Register(impl, DRPCProviderConfiguratorDescription{}) +} + +type DRPCProviderConfigurator_GetAIProvidersStream interface { + drpc.Stream + SendAndClose(*GetAIProvidersResponse) error +} + +type drpcProviderConfigurator_GetAIProvidersStream struct { + drpc.Stream +} + +func (x *drpcProviderConfigurator_GetAIProvidersStream) SendAndClose(m *GetAIProvidersResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_coderd_aibridged_proto_aibridged_proto{}); err != nil { + return err + } + return x.CloseSend() +} diff --git a/coderd/aibridged/proto/version.go b/coderd/aibridged/proto/version.go index 914189515d..fea4d6d090 100644 --- a/coderd/aibridged/proto/version.go +++ b/coderd/aibridged/proto/version.go @@ -7,9 +7,14 @@ import "github.com/coder/coder/v2/apiversion" // API v1.0: // - Initial version. Serves the Recorder, MCPConfigurator, and Authorizer // services to embedded and standalone AI Gateway daemons. +// +// API v1.1: +// - Adds the ProviderConfigurator service with the GetAIProviders unary RPC, +// letting embedded and standalone gateways fetch provider configuration +// over DRPC instead of reading the database directly. const ( CurrentMajor = 1 - CurrentMinor = 0 + CurrentMinor = 1 ) // CurrentVersion is the current aibridged API version. diff --git a/coderd/aibridged/reload.go b/coderd/aibridged/reload.go index 9909d3de0c..7e48fd45d2 100644 --- a/coderd/aibridged/reload.go +++ b/coderd/aibridged/reload.go @@ -15,7 +15,13 @@ type ProviderReloader interface { Reload(ctx context.Context) error } -// SubscribeProviderReload refreshes once, then on AI provider changes. +// SubscribeProviderReload subscribes to AI provider change events, reloading +// the reloader's snapshot on each event, and performs one initial reload +// before returning. Subscribing happens before the initial reload so no change +// event is missed. +// +// A subscription failure returns an error without reloading. The initial +// reload is best-effort: a reload failure is logged and not returned. func SubscribeProviderReload( ctx context.Context, ps dbpubsub.Pubsub, @@ -43,6 +49,7 @@ func SubscribeProviderReload( if err != nil { return nil, xerrors.Errorf("subscribe to %s: %w", pubsub.AIProvidersChangedChannel, err) } + if err := reloader.Reload(ctx); err != nil { logger.Warn(ctx, "initial ai provider reload", slog.Error(err)) } diff --git a/coderd/aibridged/reload_test.go b/coderd/aibridged/reload_test.go index e73489ba83..a604aaa692 100644 --- a/coderd/aibridged/reload_test.go +++ b/coderd/aibridged/reload_test.go @@ -59,6 +59,24 @@ func TestSubscribeProviderReloadSurfacesReloadError(t *testing.T) { "Reload must keep firing even after a previous Reload returned an error") } +func TestSubscribeProviderReloadFailsWhenSubscribeFails(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + logger := slogtest.Make(t, nil) + ps := &subscribeErrPubsub{} + + calls := &recordingReloader{} + unsub, err := aibridged.SubscribeProviderReload(ctx, ps, calls, logger) + require.Error(t, err, "a subscription failure must be surfaced to the caller") + require.Nil(t, unsub) + + // Without a subscription the snapshot can never track changes, so the + // caller must fail; no reload is attempted. + require.Equal(t, 0, calls.count()) +} + func TestSubscribeProviderReloadIgnoresEventError(t *testing.T) { t.Parallel() @@ -131,3 +149,25 @@ func (*errInjectingPubsub) Publish(string, []byte) error { func (*errInjectingPubsub) Close() error { return nil } + +var _ dbpubsub.Pubsub = &subscribeErrPubsub{} + +// subscribeErrPubsub fails every subscription attempt, exercising the path +// where SubscribeProviderReload cannot establish a subscription. +type subscribeErrPubsub struct{} + +func (*subscribeErrPubsub) Subscribe(string, dbpubsub.Listener) (func(), error) { + return nil, xerrors.New("Subscribe not implemented") +} + +func (*subscribeErrPubsub) SubscribeWithErr(string, dbpubsub.ListenerWithErr) (func(), error) { + return nil, xerrors.New("subscribe failed") +} + +func (*subscribeErrPubsub) Publish(string, []byte) error { + return xerrors.New("Publish not implemented") +} + +func (*subscribeErrPubsub) Close() error { + return nil +} diff --git a/coderd/aibridged/server.go b/coderd/aibridged/server.go index d045394c00..593a977a29 100644 --- a/coderd/aibridged/server.go +++ b/coderd/aibridged/server.go @@ -6,4 +6,5 @@ type DRPCServer interface { proto.DRPCRecorderServer proto.DRPCMCPConfiguratorServer proto.DRPCAuthorizerServer + proto.DRPCProviderConfiguratorServer } diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 469fac2219..78f11cd9cc 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -21,11 +21,13 @@ import ( "github.com/coder/coder/v2/coderd/aiseats" "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpmw" codermcp "github.com/coder/coder/v2/coderd/mcp" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" ) @@ -80,6 +82,13 @@ type store interface { // Authorizer-related queries. GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) GetUserByID(ctx context.Context, id uuid.UUID) (database.User, error) + + // ProviderConfigurator-related queries. InTx wraps the provider and key + // reads in a single read-only transaction; AcquireLock serializes against + // any in-flight env seed holding LockIDAIProvidersEnvSeed. + InTx(func(database.Store) error, *database.TxOptions) error + GetAIProviders(ctx context.Context, arg database.GetAIProvidersParams) ([]database.AIProvider, error) + GetAIProviderKeysByProviderIDs(ctx context.Context, providerIDs []uuid.UUID) ([]database.AIProviderKey, error) } type Server struct { @@ -682,6 +691,93 @@ func (s *Server) IsAuthorized(ctx context.Context, in *proto.IsAuthorizedRequest }, nil } +// GetAIProviders returns the full AI provider set (enabled and disabled) from +// the database, which is the single source of truth seeded from coderd's +// environment. Embedded and standalone AI Gateway daemons call this over DRPC +// to build their provider pool instead of reading the database directly. +// +// The handler reads under a read-only transaction that first acquires +// LockIDAIProvidersEnvSeed, so it blocks until any in-flight env seed commits +// or rolls back. This guarantees the response is never a partial, mid-seed +// snapshot. +// +// Keys are populated only for enabled providers; disabled providers never call +// upstream, so their secrets are withheld. +// +// SECURITY: the response carries plaintext API keys and Bedrock credentials. +// Do not log the response struct. +func (s *Server) GetAIProviders(ctx context.Context, _ *proto.GetAIProvidersRequest) (*proto.GetAIProvidersResponse, error) { + //nolint:gocritic // AIBridged has a minimal permission set scoped to AI Bridge queries. + ctx = dbauthz.AsAIBridged(ctx) + + var ( + rows []database.AIProvider + keysByProvider map[uuid.UUID][]database.AIProviderKey + ) + // Wrap both reads in a read-only transaction so the provider list and the + // key list are consistent with each other, and so the seed lock is held + // for the duration of the reads. + err := s.store.InTx(func(tx database.Store) error { + // Block on any in-flight seed transaction holding the advisory lock so + // the response reflects a fully-seeded snapshot. + if err := tx.AcquireLock(ctx, database.LockIDAIProvidersEnvSeed); err != nil { + return xerrors.Errorf("acquire ai providers env seed lock: %w", err) + } + + var err error + rows, err = tx.GetAIProviders(ctx, database.GetAIProvidersParams{IncludeDisabled: true}) + if err != nil { + return xerrors.Errorf("get ai providers: %w", err) + } + + // Load keys only for enabled providers to avoid materializing secrets + // for disabled rows. + ids := make([]uuid.UUID, 0, len(rows)) + for _, row := range rows { + if !row.Enabled { + continue + } + ids = append(ids, row.ID) + } + keysByProvider = make(map[uuid.UUID][]database.AIProviderKey, len(ids)) + if len(ids) == 0 { + return nil + } + keyRows, err := tx.GetAIProviderKeysByProviderIDs(ctx, ids) + if err != nil { + return xerrors.Errorf("get ai provider keys: %w", err) + } + for _, k := range keyRows { + keysByProvider[k.ProviderID] = append(keysByProvider[k.ProviderID], k) + } + return nil + }, &database.TxOptions{ReadOnly: true, TxIdentifier: "get_ai_providers"}) + if err != nil { + return nil, err + } + + providers := make([]*proto.AIProvider, 0, len(rows)) + for _, row := range rows { + p, err := aiProviderToProto(row, keysByProvider[row.ID]) + if err != nil { + // Skip the offending row rather than failing the whole fetch: + // one row with a corrupt settings blob must not break provider + // configuration for every gateway, which would otherwise loop + // forever on the empty pool. + s.logger.Error(ctx, "skipping ai provider with invalid settings; it will be absent from the gateway pool", + slog.F("provider_id", row.ID), + slog.F("provider_name", row.Name), + slog.F("provider_type", string(row.Type)), + slog.Error(err), + ) + continue + } + providers = append(providers, p) + } + + return &proto.GetAIProvidersResponse{Providers: providers}, nil +} + // Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. func getCoderMCPServerConfig(experiments codersdk.Experiments, accessURL string) (*proto.MCPServerConfig, error) { // Both the MCP & OAuth2 experiments are currently required in order to use our @@ -751,3 +847,44 @@ func parseOptionalInt32(n *int32) sql.NullInt32 { } return sql.NullInt32{Int32: *n, Valid: true} } + +// aiProviderToProto maps a single ai_providers row (and its keys, for enabled +// providers) to the proto representation served to AI Gateway daemons. Keys and +// Bedrock settings are only attached for enabled providers; disabled providers +// never call upstream so their secrets are withheld. +func aiProviderToProto(row database.AIProvider, keys []database.AIProviderKey) (*proto.AIProvider, error) { + p := &proto.AIProvider{ + Name: row.Name, + Type: string(row.Type), + Enabled: row.Enabled, + BaseUrl: row.BaseUrl, + } + // Disabled providers are rendered as stubs by the client and never call + // upstream, so only the identity fields are returned; keys and settings + // (including Bedrock credentials) are withheld. + if !row.Enabled { + return p, nil + } + + p.Keys = make([]string, 0, len(keys)) + for _, k := range keys { + p.Keys = append(p.Keys, k.APIKey) + } + + settings, err := db2sdk.AIProviderSettings(row.Settings) + if err != nil { + return nil, xerrors.Errorf("decode settings: %w", err) + } + if settings.Bedrock != nil { + p.Bedrock = &proto.AIProviderKindBedrock{ + Region: settings.Bedrock.Region, + AccessKey: ptr.NilToEmpty(settings.Bedrock.AccessKey), + AccessKeySecret: ptr.NilToEmpty(settings.Bedrock.AccessKeySecret), + Model: settings.Bedrock.Model, + SmallFastModel: settings.Bedrock.SmallFastModel, + RoleArn: settings.Bedrock.RoleARN, + } + } + + return p, nil +} diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 9f06592c8b..725f4e954a 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "net/url" + "strconv" "testing" "time" @@ -25,6 +26,7 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogjson" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/coderd/aibridged/proto" "github.com/coder/coder/v2/coderd/aibridgedserver" @@ -2405,3 +2407,207 @@ func TestInferredThreadsByToolCalls(t *testing.T) { require.Equal(t, uuid.NullUUID{UUID: bID, Valid: true}, intcC.ThreadParentID) require.Equal(t, uuid.NullUUID{UUID: aID, Valid: true}, intcC.ThreadRootID) } + +// TestGetAIProviders exercises the row-to-proto mapping over a real database: +// enabled providers carry their keys (and typed Bedrock settings), disabled +// providers are included but withhold keys and settings, Copilot (a keyless +// BYOK provider) round-trips with no keys, and an enabled provider whose +// settings blob cannot be decoded is skipped rather than failing the fetch. +func TestGetAIProviders(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + // The skipped misconfigured provider is logged at Error level by design, + // so error logs are expected here. + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + // Enabled OpenAI with two keys. + openai := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeOpenai, + Name: "openai", + Enabled: true, + BaseUrl: "https://api.openai.com/", + }) + dbgen.AIProviderKey(t, db, database.AIProviderKey{ProviderID: openai.ID, APIKey: "sk-openai-1"}) + dbgen.AIProviderKey(t, db, database.AIProviderKey{ProviderID: openai.ID, APIKey: "sk-openai-2"}) + + // Enabled Bedrock with typed settings. + bedrockSettings, err := json.Marshal(codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + Model: "anthropic.claude-3", + SmallFastModel: "anthropic.claude-haiku", + AccessKey: ptr.Ref("AKID"), + AccessKeySecret: ptr.Ref("secret"), + RoleARN: "arn:aws:iam::123456789012:role/bedrock", + }, + }) + require.NoError(t, err) + dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeBedrock, + Name: "bedrock", + Enabled: true, + BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", + Settings: sql.NullString{String: string(bedrockSettings), Valid: true}, + }) + + // Enabled Copilot, which is keyless (BYOK per request). + dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeCopilot, + Name: "copilot", + Enabled: true, + BaseUrl: "https://api.githubcopilot.com/", + }) + + // Disabled Anthropic with a key; the key must be withheld. + disabled := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeAnthropic, + Name: "anthropic-off", + BaseUrl: "https://api.anthropic.com/", + }, func(p *database.InsertAIProviderParams) { + p.Enabled = false + }) + dbgen.AIProviderKey(t, db, database.AIProviderKey{ProviderID: disabled.ID, APIKey: "sk-secret"}) + + // Enabled provider with an undecodable settings blob; it must be skipped + // so one corrupt row does not break provider config for every gateway. + dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeBedrock, + Name: "broken-settings", + Enabled: true, + BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", + Settings: sql.NullString{String: "{not valid json", Valid: true}, + }) + + srv, err := aibridgedserver.NewServer(ctx, db, logger, "/", codersdk.AIBridgeConfig{}, nil, nil, agplaiseats.Noop{}) + require.NoError(t, err) + + resp, err := srv.GetAIProviders(ctx, &proto.GetAIProvidersRequest{}) + require.NoError(t, err) + + byName := make(map[string]*proto.AIProvider, len(resp.GetProviders())) + for _, p := range resp.GetProviders() { + byName[p.GetName()] = p + } + require.Len(t, byName, 4) + assert.NotContains(t, byName, "broken-settings", "provider with undecodable settings must be skipped") + + gotOpenAI := byName["openai"] + require.NotNil(t, gotOpenAI) + assert.True(t, gotOpenAI.GetEnabled()) + assert.Equal(t, string(database.AIProviderTypeOpenai), gotOpenAI.GetType()) + assert.Equal(t, "https://api.openai.com/", gotOpenAI.GetBaseUrl()) + assert.ElementsMatch(t, []string{"sk-openai-1", "sk-openai-2"}, gotOpenAI.GetKeys()) + assert.Nil(t, gotOpenAI.GetBedrock()) + + gotBedrock := byName["bedrock"] + require.NotNil(t, gotBedrock) + assert.True(t, gotBedrock.GetEnabled()) + require.NotNil(t, gotBedrock.GetBedrock()) + assert.Equal(t, "us-east-1", gotBedrock.GetBedrock().GetRegion()) + assert.Equal(t, "anthropic.claude-3", gotBedrock.GetBedrock().GetModel()) + assert.Equal(t, "anthropic.claude-haiku", gotBedrock.GetBedrock().GetSmallFastModel()) + assert.Equal(t, "AKID", gotBedrock.GetBedrock().GetAccessKey()) + assert.Equal(t, "secret", gotBedrock.GetBedrock().GetAccessKeySecret()) + assert.Equal(t, "arn:aws:iam::123456789012:role/bedrock", gotBedrock.GetBedrock().GetRoleArn()) + + gotCopilot := byName["copilot"] + require.NotNil(t, gotCopilot) + assert.True(t, gotCopilot.GetEnabled()) + assert.Empty(t, gotCopilot.GetKeys()) + + gotDisabled := byName["anthropic-off"] + require.NotNil(t, gotDisabled) + assert.False(t, gotDisabled.GetEnabled()) + assert.Empty(t, gotDisabled.GetKeys(), "keys must be withheld for disabled providers") + assert.Nil(t, gotDisabled.GetBedrock()) +} + +// TestGetAIProvidersBlocksOnSeedLock asserts that GetAIProviders serializes on +// LockIDAIProvidersEnvSeed: while an in-flight seed transaction holds the lock, +// the fetch blocks, and once the seed commits the fetch returns the seeded +// set. Postgres advisory locks are required, so this cannot run against the +// mock store. +func TestGetAIProvidersBlocksOnSeedLock(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, nil) + + dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{ + Type: database.AIProviderTypeOpenai, + Name: "openai", + Enabled: true, + BaseUrl: "https://api.openai.com/", + }, "sk-openai") + + srv, err := aibridgedserver.NewServer(ctx, db, logger, "/", codersdk.AIBridgeConfig{}, nil, nil, agplaiseats.Noop{}) + require.NoError(t, err) + + // Simulate an in-flight env seed holding the advisory lock until released. + holderReady := make(chan struct{}) + releaseHolder := make(chan struct{}) + holderDone := make(chan struct{}) + go func() { + defer close(holderDone) + txErr := db.InTx(func(tx database.Store) error { + if err := tx.AcquireLock(ctx, database.LockIDAIProvidersEnvSeed); err != nil { + return err + } + close(holderReady) + <-releaseHolder + return nil + }, nil) + assert.NoError(t, txErr) + }() + + testutil.TryReceive(ctx, t, holderReady) + + fetchDone := make(chan *proto.GetAIProvidersResponse, 1) + fetchErr := make(chan error, 1) + go func() { + resp, err := srv.GetAIProviders(ctx, &proto.GetAIProvidersRequest{}) + fetchErr <- err + fetchDone <- resp + }() + + // Wait until the fetch goroutine is observably blocked waiting on the seed + // advisory lock, rather than inferring it from a fixed delay. AcquireLock + // uses the single-bigint advisory lock form, so the waiter appears in + // pg_locks as an ungranted "advisory" row whose objid is the low 32 bits of + // the lock ID. Asserting the wait directly stops this from passing vacuously + // if the goroutine has not yet reached the lock. + require.Eventually(t, func() bool { + locks, err := db.PGLocks(ctx) + if err != nil { + return false + } + for _, l := range locks { + if l.LockType != nil && *l.LockType == "advisory" && !l.Granted && + l.ObjID != nil && *l.ObjID == strconv.Itoa(database.LockIDAIProvidersEnvSeed) { + return true + } + } + return false + }, testutil.WaitShort, testutil.IntervalFast, "fetch must block waiting on the seed advisory lock") + + // With the fetch proven to be blocked on the lock, it must not have + // completed while the lock is still held. + select { + case <-fetchDone: + t.Fatal("GetAIProviders returned before the seed lock was released") + default: + } + + // Release the lock; the fetch should now complete and return the seeded set. + close(releaseHolder) + testutil.TryReceive(ctx, t, holderDone) + + require.NoError(t, testutil.TryReceive(ctx, t, fetchErr)) + resp := testutil.TryReceive(ctx, t, fetchDone) + require.Len(t, resp.GetProviders(), 1) + assert.Equal(t, "openai", resp.GetProviders()[0].GetName()) + assert.Equal(t, []string{"sk-openai"}, resp.GetProviders()[0].GetKeys()) +} diff --git a/coderd/aibridgedserver/register.go b/coderd/aibridgedserver/register.go index 09f5a712f0..bf52a82c7c 100644 --- a/coderd/aibridgedserver/register.go +++ b/coderd/aibridgedserver/register.go @@ -7,10 +7,10 @@ import ( "github.com/coder/coder/v2/coderd/aibridged/proto" ) -// Register registers the Recorder, MCPConfigurator, and Authorizer DRPC -// services backed by srv onto mux. It is shared by the embedded in-memory -// server and the standalone /api/v2/ai-gateway/serve WebSocket handler so both -// expose an identical service set. +// Register registers the Recorder, MCPConfigurator, Authorizer, and +// ProviderConfigurator DRPC services backed by srv onto mux. It is shared by +// the embedded in-memory server and the standalone /api/v2/ai-gateway/serve +// WebSocket handler so both expose an identical service set. func Register(mux *drpcmux.Mux, srv *Server) error { if err := proto.DRPCRegisterRecorder(mux, srv); err != nil { return xerrors.Errorf("register recorder service: %w", err) @@ -21,5 +21,8 @@ func Register(mux *drpcmux.Mux, srv *Server) error { if err := proto.DRPCRegisterAuthorizer(mux, srv); err != nil { return xerrors.Errorf("register authorizer service: %w", err) } + if err := proto.DRPCRegisterProviderConfigurator(mux, srv); err != nil { + return xerrors.Errorf("register provider configurator service: %w", err) + } return nil } diff --git a/coderd/aibridgedtest/aibridgedtest.go b/coderd/aibridgedtest/aibridgedtest.go index 9ae3511566..615212cc67 100644 --- a/coderd/aibridgedtest/aibridgedtest.go +++ b/coderd/aibridgedtest/aibridgedtest.go @@ -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 -} diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index 70d973c184..00dd9d9dc7 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -358,6 +358,7 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) { authorizedSecurityTags := []string{ "CoderSessionToken", "CoderProvisionerKey", + "AIGatewayKey", } if comment.router == "/api/v2/updatecheck" || diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index bb86f1210d..986e448656 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -11,7 +11,6 @@ import ( "golang.org/x/xerrors" - "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" @@ -78,8 +77,10 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (io.Closer, error) { 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() {} + // Without the subscription the proxy can never track provider changes, + // so fail startup rather than serve a permanently stale snapshot. + _ = srv.Close() + return nil, xerrors.Errorf("subscribe aibridgeproxyd to ai providers change channel: %w", err) } // Register the handler so coderd can serve the proxy endpoints. diff --git a/enterprise/coderd/aibridgeserve.go b/enterprise/coderd/aibridgeserve.go index 1bc639b058..f2f9fcc54d 100644 --- a/enterprise/coderd/aibridgeserve.go +++ b/enterprise/coderd/aibridgeserve.go @@ -30,11 +30,11 @@ import ( // last_heartbeat_at for its authenticating key. const aiGatewayKeyHeartbeatInterval = 60 * time.Second -// aiGatewayServe upgrades the connection to a WebSocket and serves the DRPC -// services (Recorder, MCPConfigurator, Authorizer) to a remote standalone AI -// Gateway replica, mirroring the embedded case. AI Gateway key authentication is -// enforced before the WebSocket upgrade. License entitlement is enforced by -// middleware on the route. +// aiGatewayServe upgrades the connection to a WebSocket and serves the aibridged +// DRPC services (Recorder, MCPConfigurator, Authorizer, ProviderConfigurator) to a remote standalone +// AI Gateway replica, mirroring the embedded case. AI Gateway key +// authentication is enforced before the WebSocket upgrade. License entitlement +// is enforced by middleware on the route. // // @Summary AI Gateway serve // @ID ai-gateway-serve