feat!: seed ai_providers from env on server startup (#24895)

_Disclaimer: implemented by a Coder Agent using Claude Opus 4.7_

Part of the implementation of [RFC: Common AI Provider Configs](https://www.notion.so/coderhq/RFC-Common-AI-Provider-Configs-34bd579be59280ed958feffb82024797) (AIGOV-201).

## Note

This change can cause a previously working installation to fail to start should a conflict exist between the providers configured in the environment & those now migrated to the database.

I'll raise a PR upstack to document this process and workarounds should a startup fail.

## What this PR does

Reconciles environment-derived AI provider configuration with the `ai_providers` table at server startup. The seed runs **before** the aibridged daemon is initialized, so the runtime always reads providers from the database; the legacy `CODER_AIBRIDGE_*` environment variables become a one-shot migration source.

### Behavior

- Concurrent server starts are serialized through a Postgres advisory lock (`LockIDAIProvidersEnvSeed`).
- Missing rows are inserted with an audit entry attributed to the system actor.
- Existing rows whose canonical hash matches the env-derived hash are left alone (the common no-op restart path).
- Existing rows whose canonical hash does **not** match cause server startup to fail with a descriptive error so the operator can explicitly resolve the conflict in either env or DB.
- Soft-deleted rows are NOT resurrected from env; an explicit operator deletion is sticky across restarts.
- Indexed providers whose name conflicts with a legacy env var fail startup with a clear remediation message.
- Unknown provider types (e.g. `copilot`, until the DB enum is widened) are skipped with a log entry rather than failing startup.

### Canonical hashing

The `canonicalAIProvider` shape captures exactly the fields that determine runtime behavior — `type`, `base_url`, and the Bedrock subset of settings (access key, access key secret, region, model, small fast model) — and is hashed with SHA-256. The hash is **computed on demand from the row + env**, never persisted, so the database does not need a new column for it. API keys live in the separate `ai_provider_keys` table and are intentionally excluded from the hash so operators can rotate keys via the API without forcing a server restart.

<details>
<summary>Decision log</summary>

- The hash is intentionally not persisted in the database. The RFC discussed this trade-off; computing on demand keeps the schema minimal and lets the canonical shape evolve without a migration.
- The lock uses an `iota` slot in `coderd/database/lock.go` rather than `GenLockID` so it's stable, easy to audit, and matches the convention used for every other startup lock.
- A bearer-token Anthropic provider whose env vars also set Bedrock metadata but no AWS credentials does NOT store the Bedrock fields. Without credentials the discriminated settings would misrepresent the row as Bedrock auth.
- We deliberately do NOT publish to the `ai_providers_changed` pubsub channel from the seed because the seed completes before any subscriber is started; the follow-up PR introduces that channel.

</details>
This commit is contained in:
Danny Kopping
2026-05-22 08:37:27 +02:00
committed by GitHub
parent 06526a5822
commit 9341efec9f
9 changed files with 1170 additions and 18 deletions
+61 -5
View File
@@ -862,6 +862,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
vals.AI.BridgeConfig.Providers = append(vals.AI.BridgeConfig.Providers, aiProviders...)
if err := validateLegacyAIBridgeConfig(vals.AI.BridgeConfig); err != nil {
return xerrors.Errorf("validate legacy AI bridge config: %w", err)
}
// Manage push notifications.
webpusher, err := webpush.New(ctx, ptr.Ref(options.Logger.Named("webpush")), options.Database, options.AccessURL.String())
if err != nil {
@@ -1010,6 +1014,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("create coder API: %w", err)
}
// Runs unconditionally so operators can seed providers via
// env without enabling the bridge or proxy features.
if err := coderd.SeedAIProvidersFromEnv(
ctx,
options.Database,
vals.AI.BridgeConfig,
options.Auditor,
logger.Named("aibridge.envseed"),
); err != nil {
return xerrors.Errorf("seed ai providers from env: %w", err)
}
if vals.Prometheus.Enable {
// Agent metrics require reference to the tailnet coordinator, so must be initiated after Coder API.
closeAgentsFunc, err := prometheusmetrics.Agents(ctx, logger, options.PrometheusRegistry, coderAPI.Database, &coderAPI.TailnetCoordinator, coderAPI.DERPMap, coderAPI.Options.AgentInactiveDisconnectTimeout, 0)
@@ -2961,7 +2977,20 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
i, p.Type, aibridge.ProviderOpenAI, aibridge.ProviderAnthropic, aibridge.ProviderCopilot)
}
if p.Type != aibridge.ProviderAnthropic && hasBedrockFields(*p) {
var bedrockKey, bedrockSecret string
if len(p.BedrockAccessKeys) > 0 {
bedrockKey = p.BedrockAccessKeys[0]
}
if len(p.BedrockAccessKeySecrets) > 0 {
bedrockSecret = p.BedrockAccessKeySecrets[0]
}
settings := codersdk.NewAIProviderBedrockSettings(
p.BedrockRegion, bedrockKey, bedrockSecret,
p.BedrockModel, p.BedrockSmallFastModel,
)
isBedrock := codersdk.IsBedrockConfigured(p.BedrockBaseURL, settings)
if p.Type != aibridge.ProviderAnthropic && isBedrock {
return nil, xerrors.Errorf("provider %d (%s): BEDROCK_* fields are only supported with TYPE %q",
i, p.Type, aibridge.ProviderAnthropic)
}
@@ -2971,6 +3000,15 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
i, p.Type, aibridge.ProviderCopilot)
}
// An Anthropic provider authenticates either via a bearer
// token (KEYS) or via Bedrock (BEDROCK_*), not both. Surface
// the conflict here so misconfigured deployments fail before
// any DB work happens at server startup.
if p.Type == aibridge.ProviderAnthropic && len(p.Keys) > 0 && isBedrock {
return nil, xerrors.Errorf("provider %d (%s): KEY/KEYS and BEDROCK_* fields are mutually exclusive",
i, p.Type)
}
if err := validateProviderCredentialList(i, p.Type, p.Keys); err != nil {
return nil, err
}
@@ -3092,10 +3130,28 @@ func readAIProvidersForPrefix(logger slog.Logger, environ []string, prefix strin
return providers, nil
}
func hasBedrockFields(p codersdk.AIProviderConfig) bool {
return p.BedrockBaseURL != "" || p.BedrockRegion != "" ||
len(p.BedrockAccessKeys) > 0 || len(p.BedrockAccessKeySecrets) > 0 ||
p.BedrockModel != "" || p.BedrockSmallFastModel != ""
// validateLegacyAIBridgeConfig enforces invariants on the legacy
// single-provider env vars (CODER_AIBRIDGE_ANTHROPIC_KEY,
// CODER_AIBRIDGE_BEDROCK_*) that the indexed validator above can't
// catch because legacy fields live outside cfg.Providers.
func validateLegacyAIBridgeConfig(cfg codersdk.AIBridgeConfig) error {
// An Anthropic provider authenticates either via a bearer token
// or via Bedrock, not both. Fields without serpent-level
// defaults (region, base URL, credentials) reliably indicate
// operator intent; Model and SmallFastModel are excluded because
// they have defaults.
settings := codersdk.NewAIProviderBedrockSettings(
cfg.LegacyBedrock.Region.String(),
cfg.LegacyBedrock.AccessKey.String(),
cfg.LegacyBedrock.AccessKeySecret.String(),
cfg.LegacyBedrock.Model.String(),
cfg.LegacyBedrock.SmallFastModel.String(),
)
hasBedrock := codersdk.IsBedrockConfigured(cfg.LegacyBedrock.BaseURL.String(), settings)
if cfg.LegacyAnthropic.Key.String() != "" && hasBedrock {
return xerrors.New("CODER_AIBRIDGE_ANTHROPIC_KEY and CODER_AIBRIDGE_BEDROCK_* are mutually exclusive")
}
return nil
}
// maxKeysPerProvider is the maximum number of keys allowed per
+98 -4
View File
@@ -194,12 +194,26 @@ func TestReadAIProvidersFromEnv(t *testing.T) {
},
},
{
// KEYS, BEDROCK_ACCESS_KEYS, and BEDROCK_ACCESS_KEY_SECRETS
// are plural aliases for their singular counterparts.
name: "PluralKeyAliases",
// KEYS is a plural alias for KEY.
name: "PluralKeysAlias",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-ant-xxx",
},
expected: []codersdk.AIProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: aibridge.ProviderAnthropic,
Keys: []string{"sk-ant-xxx"},
},
},
},
{
// BEDROCK_ACCESS_KEYS and BEDROCK_ACCESS_KEY_SECRETS are
// plural aliases for their singular counterparts.
name: "PluralBedrockAliases",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=secret",
},
@@ -207,12 +221,23 @@ func TestReadAIProvidersFromEnv(t *testing.T) {
{
Type: aibridge.ProviderAnthropic,
Name: aibridge.ProviderAnthropic,
Keys: []string{"sk-ant-xxx"},
BedrockAccessKeys: []string{"AKID"},
BedrockAccessKeySecrets: []string{"secret"},
},
},
},
{
// An Anthropic provider can't use both a bearer token
// (KEYS) and Bedrock (BEDROCK_*); they're mutually
// exclusive authentication modes.
name: "AnthropicKeysAndBedrockConflict",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-ant-xxx",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-east-1",
},
errContains: "KEY/KEYS and BEDROCK_* fields are mutually exclusive",
},
{
name: "ConflictKeyAndKeys",
env: []string{
@@ -443,3 +468,72 @@ func TestReadAIProvidersFromEnv(t *testing.T) {
}
})
}
func TestValidateLegacyAIBridgeConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg codersdk.AIBridgeConfig
errContains string
}{
{
name: "BareAnthropicKey",
cfg: codersdk.AIBridgeConfig{
LegacyAnthropic: codersdk.AIBridgeAnthropicConfig{Key: "sk-ant"},
},
},
{
name: "BareBedrockRegion",
cfg: codersdk.AIBridgeConfig{
LegacyBedrock: codersdk.AIBridgeBedrockConfig{Region: "us-east-1"},
},
},
{
name: "BedrockCredentialsOnly",
cfg: codersdk.AIBridgeConfig{
LegacyBedrock: codersdk.AIBridgeBedrockConfig{
AccessKey: "AKIA",
AccessKeySecret: "secret",
},
},
},
{
name: "AnthropicKeyAndBedrockConflict",
cfg: codersdk.AIBridgeConfig{
LegacyAnthropic: codersdk.AIBridgeAnthropicConfig{Key: "sk-ant"},
LegacyBedrock: codersdk.AIBridgeBedrockConfig{
Region: "us-east-1",
AccessKey: "AKIA",
AccessKeySecret: "secret",
},
},
errContains: "CODER_AIBRIDGE_ANTHROPIC_KEY and CODER_AIBRIDGE_BEDROCK_* are mutually exclusive",
},
{
name: "AnthropicKeyWithBedrockModelDefaultsIsFine",
cfg: codersdk.AIBridgeConfig{
LegacyAnthropic: codersdk.AIBridgeAnthropicConfig{Key: "sk-ant"},
// Model defaults shouldn't trip the conflict; they're
// always populated in a real deployment.
LegacyBedrock: codersdk.AIBridgeBedrockConfig{
Model: "anthropic.claude-3-5-sonnet",
SmallFastModel: "anthropic.claude-3-5-haiku",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateLegacyAIBridgeConfig(tt.cfg)
if tt.errContains == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
})
}
}