Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cli/aibridged_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,13 @@ func TestBuildProviders(t *testing.T) {
require.NoError(t, err)

names := providerNames(providers)
assert.Equal(t, []string{aibridge.ProviderAnthropic}, names)
assert.ElementsMatch(t, []string{aibridge.ProviderAnthropic, "bedrock"}, names)
})

t.Run("LegacyBedrockWithoutAnthropicKey", func(t *testing.T) {
t.Parallel()
// Bedrock credentials alone should be enough to create an
// Anthropic provider — no CODER_AIBRIDGE_ANTHROPIC_KEY needed.
// Bedrock credentials alone should be enough to create a
// Bedrock provider without CODER_AIBRIDGE_ANTHROPIC_KEY.
cfg := codersdk.AIBridgeConfig{}
cfg.LegacyBedrock.Region = serpent.String("us-west-2")
cfg.LegacyBedrock.AccessKey = serpent.String("AKID")
Expand All @@ -172,7 +172,7 @@ func TestBuildProviders(t *testing.T) {

p := providers[0]
assert.Equal(t, aibridge.ProviderAnthropic, p.Type())
assert.Equal(t, aibridge.ProviderAnthropic, p.Name())
assert.Equal(t, "bedrock", p.Name())
})

t.Run("UnknownType", func(t *testing.T) {
Expand Down
15 changes: 15 additions & 0 deletions coderd/ai_provider_canonical.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package coderd

import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/codersdk"
)

func canonicalDatabaseAIProviderType(providerType database.AIProviderType, settings codersdk.AIProviderSettings) database.AIProviderType {
return database.AIProviderType(codersdk.CanonicalAIProviderType(codersdk.AIProviderType(providerType), settings))
}

func canonicalAIProviderTypeForRow(provider database.AIProvider) (database.AIProviderType, error) {
return db2sdk.CanonicalAIProviderType(provider)
}
52 changes: 28 additions & 24 deletions coderd/ai_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
if !httpapi.Read(ctx, rw, r, &req) {
return
}
req.Type = codersdk.CanonicalAIProviderType(req.Type, req.Settings)

if validations := req.Validate(); len(validations) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Expand All @@ -168,16 +169,6 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
return
}

// Bedrock providers authenticate via the settings blob, not via a
// bearer key, so registering an api_keys list against them would
// be silently unused.
if req.Settings.Bedrock != nil && len(req.APIKeys) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock providers do not accept api_keys; configure access credentials via settings.",
})
return
}

settings, err := encodeAIProviderSettings(req.Settings)
if err != nil {
api.Logger.Error(ctx, "encode AI provider settings", slog.Error(err))
Expand Down Expand Up @@ -320,27 +311,29 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
if req.Settings != nil {
existing = mergeAIProviderSettings(existing, *req.Settings)
}
// Bedrock settings are only meaningful for anthropic- or
// bedrock-typed providers; rejecting the mismatch keeps a
// misconfiguration from sitting silently in the encrypted
// blob.
if existing.Bedrock != nil &&
old.Type != database.AiProviderTypeAnthropic &&
old.Type != database.AiProviderTypeBedrock {
targetType := canonicalDatabaseAIProviderType(old.Type, existing)
targetBaseURL := ptr.NilToDefault(req.BaseURL, old.BaseUrl)
// Bedrock settings are only meaningful for Bedrock providers;
// rejecting the mismatch keeps a misconfiguration from sitting
// silently in the encrypted blob.
if existing.Bedrock != nil && targetType != database.AiProviderTypeBedrock {
return errAIProviderBedrockTypeMismatch
}
if targetType == database.AiProviderTypeBedrock && !codersdk.IsBedrockProviderConfigured(targetBaseURL, existing.Bedrock) {
return errAIProviderBedrockSettingsRequired
}
settings, err := encodeAIProviderSettings(existing)
if err != nil {
return xerrors.Errorf("encode settings: %w", err)
}

// Reject keys against Bedrock providers (whether the existing
// row is Bedrock or the patch would make it so).
if req.APIKeys != nil && existing.Bedrock != nil && len(*req.APIKeys) > 0 {
if req.APIKeys != nil && targetType == database.AiProviderTypeBedrock && len(*req.APIKeys) > 0 {
return errBedrockRejectsAPIKeys
}

if req.APIKeys != nil && old.Type == database.AiProviderTypeCopilot && len(*req.APIKeys) > 0 {
if req.APIKeys != nil && targetType == database.AiProviderTypeCopilot && len(*req.APIKeys) > 0 {
return errCopilotRejectsAPIKeys
}

Expand All @@ -351,9 +344,10 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
}
params := database.UpdateAIProviderParams{
ID: old.ID,
Type: targetType,
DisplayName: displayName,
Enabled: ptr.NilToDefault(req.Enabled, old.Enabled),
BaseUrl: ptr.NilToDefault(req.BaseURL, old.BaseUrl),
BaseUrl: targetBaseURL,
Settings: settings,
// SettingsKeyID is set by the dbcrypt wrapper.
SettingsKeyID: sql.NullString{},
Expand Down Expand Up @@ -393,9 +387,15 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
})
return
}
if errors.Is(err, errAIProviderBedrockSettingsRequired) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "type=bedrock requires bedrock settings or base_url.",
})
return
}
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock settings are only valid for type=anthropic or type=bedrock.",
Message: "Bedrock settings are only valid for type=bedrock.",
})
return
}
Expand Down Expand Up @@ -501,9 +501,13 @@ var errCopilotRejectsAPIKeys = xerrors.New("copilot providers do not accept api_

// errAIProviderBedrockTypeMismatch is the sentinel returned from
// inside the update transaction when the post-merge settings carry a
// Bedrock block but the provider is not anthropic- or bedrock-typed;
// the outer handler translates it into a 400.
var errAIProviderBedrockTypeMismatch = xerrors.New("bedrock settings are only valid for type=anthropic or type=bedrock")
// Bedrock block but the provider is not Bedrock-typed; the outer handler
// translates it into a 400.
var errAIProviderBedrockTypeMismatch = xerrors.New("bedrock settings are only valid for type=bedrock")

// errAIProviderBedrockSettingsRequired is returned when a Bedrock provider
// would be stored without enough Bedrock connection settings.
var errAIProviderBedrockSettingsRequired = xerrors.New("type=bedrock requires bedrock settings or base_url")

// errAIProviderInvalidName is returned from lookupAIProvider when the
// idOrName parameter is neither a UUID nor a syntactically-valid name.
Expand Down
73 changes: 49 additions & 24 deletions coderd/ai_providers_migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,31 @@ func SeedAIProvidersFromEnv(
}

existing, found := byName[dp.Name]
if found && !existing.Deleted && dp.Type == database.AiProviderTypeAnthropic {
existingSettings, err := db2sdk.AIProviderSettings(existing.Settings)
if err != nil {
return xerrors.Errorf("decode existing settings for %q: %w", existing.Name, err)
}
if canonicalDatabaseAIProviderType(existing.Type, existingSettings) == database.AiProviderTypeBedrock {
logger.Warn(sysCtx, "skipping legacy Anthropic env seed because an existing Anthropic-named row contains Bedrock settings",
slog.F("name", dp.Name),
)
continue
}
}
if !found && dp.Type == database.AiProviderTypeBedrock {
candidate, ok := byName[aibridge.ProviderAnthropic]
if ok {
candidateSettings, err := db2sdk.AIProviderSettings(candidate.Settings)
if err != nil {
return xerrors.Errorf("decode existing settings for %q: %w", candidate.Name, err)
}
if canonicalDatabaseAIProviderType(candidate.Type, candidateSettings) == database.AiProviderTypeBedrock {
existing = candidate
found = true
}
}
}
Comment thread
ibetitsmike marked this conversation as resolved.
switch {
case found && existing.Deleted:
// The provider was created here, then explicitly
Expand All @@ -127,7 +152,7 @@ func SeedAIProvidersFromEnv(
existingKeys = append(existingKeys, k.APIKey)
}
existingDP := desiredAIProvider{
Type: existing.Type,
Type: canonicalDatabaseAIProviderType(existing.Type, existingSettings),
BaseURL: existing.BaseUrl,
Bedrock: existingSettings.Bedrock,
Keys: existingKeys,
Expand All @@ -136,6 +161,9 @@ func SeedAIProvidersFromEnv(
if existingHash == dp.Hash {
continue
}
if existing.Name != dp.Name {
return xerrors.Errorf("AI provider %q matches existing legacy row %q and differs from the current environment configuration; update the provider through the API or remove the CODER_AIBRIDGE_* env vars to stop seeding it", dp.Name, existing.Name)
Comment thread
ibetitsmike marked this conversation as resolved.
}
return xerrors.Errorf("AI provider %q already exists in the database and differs from the current environment configuration; update the provider through the API or remove the CODER_AIBRIDGE_* env vars to stop seeding it", dp.Name)
}

Expand Down Expand Up @@ -310,11 +338,9 @@ func providersFromEnv(ctx context.Context, cfg codersdk.AIBridgeConfig, logger s
addLegacy(aibridge.ProviderOpenAI, dp)
}

// Legacy Anthropic + Bedrock. Anthropic is enabled if either an
// Anthropic key OR any Bedrock setting is explicitly configured.
// Detection goes through AIProviderBedrockSettings.IsConfigured()
// so the legacy and indexed paths agree on what counts as a
// Bedrock provider.
// Legacy Anthropic and Bedrock env vars seed independent providers.
// Detection goes through IsBedrockConfigured so the legacy and
// indexed paths agree on what counts as a Bedrock provider.
bedrock := codersdk.NewAIProviderBedrockSettings(
cfg.LegacyBedrock.Region.String(),
cfg.LegacyBedrock.AccessKey.String(),
Expand All @@ -323,29 +349,27 @@ func providersFromEnv(ctx context.Context, cfg codersdk.AIBridgeConfig, logger s
cfg.LegacyBedrock.SmallFastModel.String(),
)
hasAnthropicKey := cfg.LegacyAnthropic.Key.String() != ""
hasLegacyBedrock := codersdk.IsBedrockConfigured(cfg.LegacyBedrock.BaseURL.String(), bedrock)
if hasAnthropicKey || hasLegacyBedrock {
if hasAnthropicKey {
dp := desiredAIProvider{
Name: aibridge.ProviderAnthropic,
Type: database.AiProviderTypeAnthropic,
}
if hasLegacyBedrock {
if hasAnthropicKey {
logger.Warn(ctx, "ignoring legacy Anthropic API key because Bedrock credentials are configured; Bedrock authenticates via access keys or credential chain",
slog.F("provider", aibridge.ProviderAnthropic),
)
}
// Bedrock-only deployments use CODER_AIBRIDGE_BEDROCK_BASE_URL
// for custom VPC, FIPS, or proxy endpoints.
dp.BaseURL = cfg.LegacyBedrock.BaseURL.String()
dp.Bedrock = &bedrock
} else {
dp.BaseURL = cfg.LegacyAnthropic.BaseURL.String()
dp.Keys = []string{cfg.LegacyAnthropic.Key.String()}
Name: aibridge.ProviderAnthropic,
Type: database.AiProviderTypeAnthropic,
BaseURL: cfg.LegacyAnthropic.BaseURL.String(),
Keys: []string{cfg.LegacyAnthropic.Key.String()},
}
dp.Hash = computeProviderHash(dp.canonical())
addLegacy(aibridge.ProviderAnthropic, dp)
}
hasLegacyBedrock := codersdk.IsBedrockConfigured(cfg.LegacyBedrock.BaseURL.String(), bedrock)
if hasLegacyBedrock {
dp := desiredAIProvider{
Name: "bedrock",
Type: database.AiProviderTypeBedrock,
BaseURL: cfg.LegacyBedrock.BaseURL.String(),
Bedrock: &bedrock,
}
dp.Hash = computeProviderHash(dp.canonical())
addLegacy(dp.Name, dp)
}

// Indexed providers.
for _, p := range cfg.Providers {
Expand Down Expand Up @@ -398,6 +422,7 @@ func providersFromEnv(ctx context.Context, cfg codersdk.AIBridgeConfig, logger s
)
isBedrock = codersdk.IsBedrockConfigured(p.BedrockBaseURL, bedrock)
if isBedrock {
dp.Type = database.AiProviderTypeBedrock
dp.Bedrock = &bedrock
// Always overwrite the generic BaseURL so removing
// BASE_URL later doesn't trigger drift. Empty is fine:
Expand Down
Loading
Loading