Skip to content

Navigation Menu

Sign in
Appearance settings

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

Provide feedback

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

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 08045c2

Browse filesBrowse files
authored
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>
1 parent 730edba commit 08045c2
Copy full SHA for 08045c2

17 files changed

+1,323-160Lines changed: 1323 additions & 160 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎cli/server.go‎

Copy file name to clipboardExpand all lines: cli/server.go
+123Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import (
5656

5757
"cdr.dev/slog/v3"
5858
"cdr.dev/slog/v3/sloggers/sloghuman"
59+
"github.com/coder/aibridge"
5960
"github.com/coder/coder/v2/buildinfo"
6061
"github.com/coder/coder/v2/cli/clilog"
6162
"github.com/coder/coder/v2/cli/cliui"
@@ -842,6 +843,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
842843
)
843844
}
844845

846+
aibridgeProviders, err := ReadAIBridgeProvidersFromEnv(logger, os.Environ())
847+
if err != nil {
848+
return xerrors.Errorf("read aibridge providers from env: %w", err)
849+
}
850+
vals.AI.BridgeConfig.Providers = append(vals.AI.BridgeConfig.Providers, aibridgeProviders...)
851+
845852
// Manage push notifications.
846853
webpusher, err := webpush.New(ctx, ptr.Ref(options.Logger.Named("webpush")), options.Database, options.AccessURL.String())
847854
if err != nil {
@@ -2901,6 +2908,122 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
29012908
return providers, nil
29022909
}
29032910

2911+
// ReadAIBridgeProvidersFromEnv parses CODER_AIBRIDGE_PROVIDER_<N>_<KEY>
2912+
// environment variables into a slice of AIBridgeProviderConfig.
2913+
// This follows the same indexed pattern as ReadExternalAuthProvidersFromEnv.
2914+
func ReadAIBridgeProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AIBridgeProviderConfig, error) {
2915+
parsed := serpent.ParseEnviron(environ, "CODER_AIBRIDGE_PROVIDER_")
2916+
2917+
// Sort by numeric index so that PROVIDER_2 comes before PROVIDER_10.
2918+
slices.SortFunc(parsed, func(a, b serpent.EnvVar) int {
2919+
aIdx, _ := strconv.Atoi(strings.SplitN(a.Name, "_", 2)[0])
2920+
bIdx, _ := strconv.Atoi(strings.SplitN(b.Name, "_", 2)[0])
2921+
if aIdx != bIdx {
2922+
return aIdx - bIdx
2923+
}
2924+
return strings.Compare(a.Name, b.Name)
2925+
})
2926+
2927+
var providers []codersdk.AIBridgeProviderConfig
2928+
for _, v := range parsed {
2929+
tokens := strings.SplitN(v.Name, "_", 2)
2930+
if len(tokens) != 2 {
2931+
return nil, xerrors.Errorf("invalid env var: %s", v.Name)
2932+
}
2933+
2934+
providerNum, err := strconv.Atoi(tokens[0])
2935+
if err != nil {
2936+
return nil, xerrors.Errorf("parse number: %s", v.Name)
2937+
}
2938+
2939+
var provider codersdk.AIBridgeProviderConfig
2940+
switch {
2941+
case len(providers) < providerNum:
2942+
return nil, xerrors.Errorf(
2943+
"provider num %v skipped: %s",
2944+
len(providers),
2945+
v.Name,
2946+
)
2947+
case len(providers) == providerNum: // First observation of this index, create a new provider.
2948+
providers = append(providers, provider)
2949+
case len(providers) == providerNum+1: // Provider already exists at this index, update it.
2950+
provider = providers[providerNum]
2951+
}
2952+
2953+
key := tokens[1]
2954+
switch key {
2955+
case "TYPE":
2956+
provider.Type = v.Value
2957+
case "NAME":
2958+
provider.Name = v.Value
2959+
case "KEY": // Alias for a single key.
2960+
provider.Key = v.Value
2961+
case "KEYS":
2962+
provider.Key = v.Value
2963+
case "BASE_URL":
2964+
provider.BaseURL = v.Value
2965+
case "BEDROCK_BASE_URL":
2966+
provider.BedrockBaseURL = v.Value
2967+
case "BEDROCK_REGION":
2968+
provider.BedrockRegion = v.Value
2969+
case "BEDROCK_ACCESS_KEY": // Alias for a single key.
2970+
provider.BedrockAccessKey = v.Value
2971+
case "BEDROCK_ACCESS_KEYS":
2972+
provider.BedrockAccessKey = v.Value
2973+
case "BEDROCK_ACCESS_KEY_SECRET": // Alias for a single key secret.
2974+
provider.BedrockAccessKeySecret = v.Value
2975+
case "BEDROCK_ACCESS_KEY_SECRETS":
2976+
provider.BedrockAccessKeySecret = v.Value
2977+
case "BEDROCK_MODEL":
2978+
provider.BedrockModel = v.Value
2979+
case "BEDROCK_SMALL_FAST_MODEL":
2980+
provider.BedrockSmallFastModel = v.Value
2981+
default:
2982+
logger.Warn(context.Background(), "ignoring unknown aibridge provider field (check for typos)",
2983+
slog.F("env", fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_%s", providerNum, key)),
2984+
)
2985+
}
2986+
providers[providerNum] = provider
2987+
}
2988+
2989+
// Post-parse validation.
2990+
names := make(map[string]int, len(providers))
2991+
for i := range providers {
2992+
p := &providers[i]
2993+
if p.Type == "" {
2994+
return nil, xerrors.Errorf("provider %d: TYPE is required", i)
2995+
}
2996+
2997+
switch p.Type {
2998+
case aibridge.ProviderOpenAI, aibridge.ProviderAnthropic, aibridge.ProviderCopilot:
2999+
default:
3000+
return nil, xerrors.Errorf("provider %d: unknown TYPE %q (must be %s, %s, or %s)",
3001+
i, p.Type, aibridge.ProviderOpenAI, aibridge.ProviderAnthropic, aibridge.ProviderCopilot)
3002+
}
3003+
3004+
if p.Type != aibridge.ProviderAnthropic && hasBedrockFields(*p) {
3005+
return nil, xerrors.Errorf("provider %d (%s): BEDROCK_* fields are only supported with TYPE %q",
3006+
i, p.Type, aibridge.ProviderAnthropic)
3007+
}
3008+
3009+
if p.Name == "" {
3010+
p.Name = p.Type
3011+
}
3012+
if other, exists := names[p.Name]; exists {
3013+
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)
3014+
}
3015+
names[p.Name] = i
3016+
}
3017+
3018+
return providers, nil
3019+
}
3020+
3021+
func hasBedrockFields(p codersdk.AIBridgeProviderConfig) bool {
3022+
return p.BedrockBaseURL != "" || p.BedrockRegion != "" ||
3023+
p.BedrockAccessKey != "" || p.BedrockAccessKeySecret != "" ||
3024+
p.BedrockModel != "" || p.BedrockSmallFastModel != ""
3025+
}
3026+
29043027
var reInvalidPortAfterHost = regexp.MustCompile(`invalid port ".+" after host`)
29053028

29063029
// If the user provides a postgres URL with a password that contains special

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.