feat: configure multiple AI Bridge providers of the same type (#23948)

_Disclaimer: produced mostly by Claude Opus 4.6 following detailed
planning._

## Summary
- Support multiple instances of the same AI Bridge provider type via
indexed env vars (`CODER_AIBRIDGE_PROVIDER_<N>_<KEY>`), following the
`CODER_EXTERNAL_AUTH_<N>_<KEY>` pattern
- Existing single-provider env vars (`CODER_AIBRIDGE_OPENAI_KEY`, etc.)
continue to work unchanged
- Setting both a legacy env var and an indexed provider with the same
name errors at startup to prevent silent misconfiguration
- Mark legacy provider fields (`OpenAI`, `Anthropic`, `Bedrock`) as
deprecated in `AIBridgeConfig` in favor of `Providers`
  ## Example
```sh
CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic
CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-corp
CODER_AIBRIDGE_PROVIDER_0_KEY=sk-ant-corp-xxx

CODER_AIBRIDGE_PROVIDER_0_BASE_URL=https://llm-proxy.internal.example.com/anthropic

CODER_AIBRIDGE_PROVIDER_1_TYPE=anthropic
CODER_AIBRIDGE_PROVIDER_1_NAME=anthropic-direct
  CODER_AIBRIDGE_PROVIDER_1_KEY=sk-ant-direct-yyy         
  ```
  Each instance is routed by name:
- /api/v2/aibridge/**anthropic-corp**/v1/messages
- /api/v2/aibridge/**anthropic-direct**/v1/messages
Closes
[AIGOV-157](https://linear.app/codercom/issue/AIGOV-157/spike-to-understand-if-there-is-a-simple-way-to-handle-multi-api-key)

---------

Signed-off-by: Danny Kopping <danny@coder.com>
This commit is contained in:
Danny Kopping
2026-04-15 07:59:37 +00:00
committed by GitHub
parent 730edba87a
commit 08045c2aac
17 changed files with 1326 additions and 163 deletions
+123
View File
@@ -56,6 +56,7 @@ import (
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/aibridge"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/cli/cliui"
@@ -842,6 +843,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
)
}
aibridgeProviders, err := ReadAIBridgeProvidersFromEnv(logger, os.Environ())
if err != nil {
return xerrors.Errorf("read aibridge providers from env: %w", err)
}
vals.AI.BridgeConfig.Providers = append(vals.AI.BridgeConfig.Providers, aibridgeProviders...)
// Manage push notifications.
webpusher, err := webpush.New(ctx, ptr.Ref(options.Logger.Named("webpush")), options.Database, options.AccessURL.String())
if err != nil {
@@ -2901,6 +2908,122 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
return providers, nil
}
// ReadAIBridgeProvidersFromEnv parses CODER_AIBRIDGE_PROVIDER_<N>_<KEY>
// environment variables into a slice of AIBridgeProviderConfig.
// This follows the same indexed pattern as ReadExternalAuthProvidersFromEnv.
func ReadAIBridgeProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AIBridgeProviderConfig, error) {
parsed := serpent.ParseEnviron(environ, "CODER_AIBRIDGE_PROVIDER_")
// Sort by numeric index so that PROVIDER_2 comes before PROVIDER_10.
slices.SortFunc(parsed, func(a, b serpent.EnvVar) int {
aIdx, _ := strconv.Atoi(strings.SplitN(a.Name, "_", 2)[0])
bIdx, _ := strconv.Atoi(strings.SplitN(b.Name, "_", 2)[0])
if aIdx != bIdx {
return aIdx - bIdx
}
return strings.Compare(a.Name, b.Name)
})
var providers []codersdk.AIBridgeProviderConfig
for _, v := range parsed {
tokens := strings.SplitN(v.Name, "_", 2)
if len(tokens) != 2 {
return nil, xerrors.Errorf("invalid env var: %s", v.Name)
}
providerNum, err := strconv.Atoi(tokens[0])
if err != nil {
return nil, xerrors.Errorf("parse number: %s", v.Name)
}
var provider codersdk.AIBridgeProviderConfig
switch {
case len(providers) < providerNum:
return nil, xerrors.Errorf(
"provider num %v skipped: %s",
len(providers),
v.Name,
)
case len(providers) == providerNum: // First observation of this index, create a new provider.
providers = append(providers, provider)
case len(providers) == providerNum+1: // Provider already exists at this index, update it.
provider = providers[providerNum]
}
key := tokens[1]
switch key {
case "TYPE":
provider.Type = v.Value
case "NAME":
provider.Name = v.Value
case "KEY": // Alias for a single key.
provider.Key = v.Value
case "KEYS":
provider.Key = v.Value
case "BASE_URL":
provider.BaseURL = v.Value
case "BEDROCK_BASE_URL":
provider.BedrockBaseURL = v.Value
case "BEDROCK_REGION":
provider.BedrockRegion = v.Value
case "BEDROCK_ACCESS_KEY": // Alias for a single key.
provider.BedrockAccessKey = v.Value
case "BEDROCK_ACCESS_KEYS":
provider.BedrockAccessKey = v.Value
case "BEDROCK_ACCESS_KEY_SECRET": // Alias for a single key secret.
provider.BedrockAccessKeySecret = v.Value
case "BEDROCK_ACCESS_KEY_SECRETS":
provider.BedrockAccessKeySecret = v.Value
case "BEDROCK_MODEL":
provider.BedrockModel = v.Value
case "BEDROCK_SMALL_FAST_MODEL":
provider.BedrockSmallFastModel = v.Value
default:
logger.Warn(context.Background(), "ignoring unknown aibridge provider field (check for typos)",
slog.F("env", fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_%s", providerNum, key)),
)
}
providers[providerNum] = provider
}
// Post-parse validation.
names := make(map[string]int, len(providers))
for i := range providers {
p := &providers[i]
if p.Type == "" {
return nil, xerrors.Errorf("provider %d: TYPE is required", i)
}
switch p.Type {
case aibridge.ProviderOpenAI, aibridge.ProviderAnthropic, aibridge.ProviderCopilot:
default:
return nil, xerrors.Errorf("provider %d: unknown TYPE %q (must be %s, %s, or %s)",
i, p.Type, aibridge.ProviderOpenAI, aibridge.ProviderAnthropic, aibridge.ProviderCopilot)
}
if p.Type != aibridge.ProviderAnthropic && hasBedrockFields(*p) {
return nil, xerrors.Errorf("provider %d (%s): BEDROCK_* fields are only supported with TYPE %q",
i, p.Type, aibridge.ProviderAnthropic)
}
if p.Name == "" {
p.Name = p.Type
}
if other, exists := names[p.Name]; exists {
return nil, xerrors.Errorf("providers %d and %d have duplicate NAME %q (multiple providers of the same type require unique NAME values)", other, i, p.Name)
}
names[p.Name] = i
}
return providers, nil
}
func hasBedrockFields(p codersdk.AIBridgeProviderConfig) bool {
return p.BedrockBaseURL != "" || p.BedrockRegion != "" ||
p.BedrockAccessKey != "" || p.BedrockAccessKeySecret != "" ||
p.BedrockModel != "" || p.BedrockSmallFastModel != ""
}
var reInvalidPortAfterHost = regexp.MustCompile(`invalid port ".+" after host`)
// If the user provides a postgres URL with a password that contains special
+259
View File
@@ -0,0 +1,259 @@
package cli
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/aibridge"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestReadAIBridgeProvidersFromEnv(t *testing.T) {
t.Parallel()
tests := []struct {
name string
env []string
expected []codersdk.AIBridgeProviderConfig
errContains string
}{
{
name: "Empty",
env: []string{"HOME=/home/frodo"},
},
{
name: "SingleProvider",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-zdr",
"CODER_AIBRIDGE_PROVIDER_0_KEY=sk-ant-xxx",
"CODER_AIBRIDGE_PROVIDER_0_BASE_URL=https://api.anthropic.com/",
},
expected: []codersdk.AIBridgeProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: "anthropic-zdr",
Key: "sk-ant-xxx",
BaseURL: "https://api.anthropic.com/",
},
},
},
{
name: "MultipleProvidersSameType",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-us",
"CODER_AIBRIDGE_PROVIDER_1_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_1_NAME=anthropic-eu",
"CODER_AIBRIDGE_PROVIDER_1_BASE_URL=https://eu.api.anthropic.com/",
},
expected: []codersdk.AIBridgeProviderConfig{
{Type: aibridge.ProviderAnthropic, Name: "anthropic-us"},
{Type: aibridge.ProviderAnthropic, Name: "anthropic-eu", BaseURL: "https://eu.api.anthropic.com/"},
},
},
{
name: "DefaultName",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
},
expected: []codersdk.AIBridgeProviderConfig{
{Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI},
},
},
{
name: "MixedTypes",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-main",
"CODER_AIBRIDGE_PROVIDER_1_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_2_TYPE=copilot",
"CODER_AIBRIDGE_PROVIDER_2_NAME=copilot-custom",
"CODER_AIBRIDGE_PROVIDER_2_BASE_URL=https://custom.copilot.com",
},
expected: []codersdk.AIBridgeProviderConfig{
{Type: aibridge.ProviderAnthropic, Name: "anthropic-main"},
{Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI},
{Type: aibridge.ProviderCopilot, Name: "copilot-custom", BaseURL: "https://custom.copilot.com"},
},
},
{
name: "BedrockFields",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-bedrock",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-west-2",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY=AKID",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRET=secret",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_MODEL=anthropic.claude-3-sonnet",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_SMALL_FAST_MODEL=anthropic.claude-3-haiku",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_BASE_URL=https://bedrock.us-west-2.amazonaws.com",
},
expected: []codersdk.AIBridgeProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: "anthropic-bedrock",
BedrockRegion: "us-west-2",
BedrockAccessKey: "AKID",
BedrockAccessKeySecret: "secret",
BedrockModel: "anthropic.claude-3-sonnet",
BedrockSmallFastModel: "anthropic.claude-3-haiku",
BedrockBaseURL: "https://bedrock.us-west-2.amazonaws.com",
},
},
},
{
name: "OutOfOrderIndices",
env: []string{
"CODER_AIBRIDGE_PROVIDER_1_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_1_NAME=second",
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_NAME=first",
},
expected: []codersdk.AIBridgeProviderConfig{
{Type: aibridge.ProviderOpenAI, Name: "first"},
{Type: aibridge.ProviderAnthropic, Name: "second"},
},
},
{
name: "SkippedIndex",
env: []string{"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai", "CODER_AIBRIDGE_PROVIDER_2_TYPE=anthropic"},
errContains: "skipped",
},
{
name: "InvalidKey",
env: []string{"CODER_AIBRIDGE_PROVIDER_XXX_TYPE=openai"},
errContains: "parse number",
},
{
name: "MissingType",
env: []string{"CODER_AIBRIDGE_PROVIDER_0_NAME=my-provider", "CODER_AIBRIDGE_PROVIDER_0_KEY=sk-xxx"},
errContains: "TYPE is required",
},
{
name: "InvalidType",
env: []string{"CODER_AIBRIDGE_PROVIDER_0_TYPE=gemini"},
errContains: "unknown TYPE",
},
{
name: "DuplicateExplicitNames",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_NAME=my-provider",
"CODER_AIBRIDGE_PROVIDER_1_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_1_NAME=my-provider",
},
errContains: "duplicate NAME",
},
{
name: "DuplicateDefaultNames",
env: []string{"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic", "CODER_AIBRIDGE_PROVIDER_1_TYPE=anthropic"},
errContains: "duplicate NAME",
},
{
name: "BedrockFieldsOnNonAnthropic",
env: []string{"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai", "CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-west-2"},
errContains: "BEDROCK_* fields are only supported with TYPE",
},
{
name: "IgnoresUnrelatedEnvVars",
env: []string{
"CODER_AIBRIDGE_OPENAI_KEY=should-be-ignored",
"CODER_AIBRIDGE_ANTHROPIC_KEY=also-ignored",
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_KEY=sk-xxx",
"SOME_OTHER_VAR=hello",
},
expected: []codersdk.AIBridgeProviderConfig{
{Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Key: "sk-xxx"},
},
},
{
// KEYS, BEDROCK_ACCESS_KEYS, and BEDROCK_ACCESS_KEY_SECRETS
// are plural aliases for their singular counterparts.
name: "PluralKeyAliases",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-ant-xxx",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=secret",
},
expected: []codersdk.AIBridgeProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: aibridge.ProviderAnthropic,
Key: "sk-ant-xxx",
BedrockAccessKey: "AKID",
BedrockAccessKeySecret: "secret",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
providers, err := ReadAIBridgeProvidersFromEnv(slogtest.Make(t, nil), tt.env)
if tt.errContains != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
return
}
require.NoError(t, err)
require.Equal(t, tt.expected, providers)
})
}
// Cases below need special setup that doesn't fit the table above.
t.Run("MultiDigitIndices", func(t *testing.T) {
t.Parallel()
// Indices 0, 1, 2, ..., 10 — verifies that 10 sorts after 2,
// not between 1 and 2 as a lexicographic sort would do.
var env []string
var expected []codersdk.AIBridgeProviderConfig
for i := range 11 {
env = append(env,
fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_TYPE=openai", i),
fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_KEY=sk-%d", i, i),
fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_NAME=p%d", i, i),
)
expected = append(expected, codersdk.AIBridgeProviderConfig{
Type: aibridge.ProviderOpenAI,
Name: fmt.Sprintf("p%d", i),
Key: fmt.Sprintf("sk-%d", i),
})
}
providers, err := ReadAIBridgeProvidersFromEnv(slogtest.Make(t, nil), env)
require.NoError(t, err)
require.Equal(t, expected, providers)
})
t.Run("UnknownFieldWarnsButSucceeds", func(t *testing.T) {
t.Parallel()
// A typo like TPYE instead of TYPE should not prevent startup;
// the function logs a warning and continues.
sink := testutil.NewFakeSink(t)
providers, err := ReadAIBridgeProvidersFromEnv(sink.Logger(), []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_TPYE=openai",
})
require.NoError(t, err)
require.Equal(t, []codersdk.AIBridgeProviderConfig{
{Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI},
}, providers)
warnings := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ignoring unknown aibridge provider field (check for typos)"
})
require.Len(t, warnings, 1)
require.Len(t, warnings[0].Fields, 1)
assert.Equal(t, "CODER_AIBRIDGE_PROVIDER_0_TPYE", warnings[0].Fields[0].Value)
})
}
+5 -15
View File
@@ -858,21 +858,11 @@ aibridgeproxy:
# clients.
# (default: <unset>, type: string)
key_file: ""
# Comma-separated list of AI provider domains for which HTTPS traffic will be
# decrypted and routed through AI Bridge. Requests to other domains will be
# tunneled directly without decryption. Supported domains: api.anthropic.com,
# api.openai.com, api.individual.githubcopilot.com,
# api.business.githubcopilot.com, api.enterprise.githubcopilot.com, chatgpt.com.
# (default:
# api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,chatgpt.com,
# type: string-array)
domain_allowlist:
- api.anthropic.com
- api.openai.com
- api.individual.githubcopilot.com
- api.business.githubcopilot.com
- api.enterprise.githubcopilot.com
- chatgpt.com
# Deprecated: This value is now derived automatically from the configured AI
# Bridge providers' base URLs. Setting this value has no effect. This option will
# be removed in a future release.
# (default: <unset>, type: string-array)
domain_allowlist: []
# URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests
# through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port.
# (default: <unset>, type: string)