From 08045c2aac8cf260071afeb04882fe43edea2e95 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 15 Apr 2026 09:59:37 +0200 Subject: [PATCH] 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__`), following the `CODER_EXTERNAL_AUTH__` 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 --- cli/server.go | 123 ++++++++ cli/server_aibridge_internal_test.go | 259 +++++++++++++++++ cli/testdata/server-config.yaml.golden | 20 +- coderd/apidoc/docs.go | 54 +++- coderd/apidoc/swagger.json | 54 +++- coderd/exp_chats_test.go | 16 +- codersdk/deployment.go | 77 +++-- docs/ai-coder/ai-gateway/clients/copilot.md | 27 ++ docs/ai-coder/ai-gateway/setup.md | 91 +++++- docs/reference/api/general.md | 10 + docs/reference/api/schemas.md | 99 +++++-- .../aibridgeproxyd/aibridgeproxyd_test.go | 58 ++++ enterprise/cli/aibridged.go | 169 +++++++---- enterprise/cli/aibridged_internal_test.go | 269 ++++++++++++++++++ enterprise/cli/aibridgeproxyd.go | 62 +++- enterprise/cli/server.go | 62 ++-- site/src/api/typesGenerated.ts | 39 +++ 17 files changed, 1326 insertions(+), 163 deletions(-) create mode 100644 cli/server_aibridge_internal_test.go create mode 100644 enterprise/cli/aibridged_internal_test.go diff --git a/cli/server.go b/cli/server.go index 874212f739..0604b06bc4 100644 --- a/cli/server.go +++ b/cli/server.go @@ -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__ +// 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 diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go new file mode 100644 index 0000000000..35e9e6400e --- /dev/null +++ b/cli/server_aibridge_internal_test.go @@ -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) + }) +} diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 951b547217..bd1fc4cd68 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -858,21 +858,11 @@ aibridgeproxy: # clients. # (default: , 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: , 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: , type: string) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index dc1fb32c27..c0f6ecb23d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13103,10 +13103,20 @@ const docTemplate = `{ "type": "object", "properties": { "anthropic": { - "$ref": "#/definitions/codersdk.AIBridgeAnthropicConfig" + "description": "Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_* env vars instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeAnthropicConfig" + } + ] }, "bedrock": { - "$ref": "#/definitions/codersdk.AIBridgeBedrockConfig" + "description": "Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_* env vars instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeBedrockConfig" + } + ] }, "circuit_breaker_enabled": { "description": "Circuit breaker protects against cascading failures from upstream AI\nprovider rate limits (429, 503, 529 overloaded).", @@ -13135,7 +13145,19 @@ const docTemplate = `{ "type": "integer" }, "openai": { - "$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig" + "description": "Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_* env vars instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig" + } + ] + }, + "providers": { + "description": "Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_\u003cKEY\u003e\nenv vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeProviderConfig" + } }, "rate_limit": { "type": "integer" @@ -13255,6 +13277,32 @@ const docTemplate = `{ } } }, + "codersdk.AIBridgeProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL of the upstream provider API.", + "type": "string" + }, + "bedrock_model": { + "type": "string" + }, + "bedrock_region": { + "type": "string" + }, + "bedrock_small_fast_model": { + "type": "string" + }, + "name": { + "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", + "type": "string" + }, + "type": { + "description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".", + "type": "string" + } + } + }, "codersdk.AIBridgeProxyConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1a98288345..31f5dd45e2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11651,10 +11651,20 @@ "type": "object", "properties": { "anthropic": { - "$ref": "#/definitions/codersdk.AIBridgeAnthropicConfig" + "description": "Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_* env vars instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeAnthropicConfig" + } + ] }, "bedrock": { - "$ref": "#/definitions/codersdk.AIBridgeBedrockConfig" + "description": "Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_* env vars instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeBedrockConfig" + } + ] }, "circuit_breaker_enabled": { "description": "Circuit breaker protects against cascading failures from upstream AI\nprovider rate limits (429, 503, 529 overloaded).", @@ -11683,7 +11693,19 @@ "type": "integer" }, "openai": { - "$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig" + "description": "Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_* env vars instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig" + } + ] + }, + "providers": { + "description": "Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_\u003cKEY\u003e\nenv vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeProviderConfig" + } }, "rate_limit": { "type": "integer" @@ -11803,6 +11825,32 @@ } } }, + "codersdk.AIBridgeProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL of the upstream provider API.", + "type": "string" + }, + "bedrock_model": { + "type": "string" + }, + "bedrock_region": { + "type": "string" + }, + "bedrock_small_fast_model": { + "type": "string" + }, + "name": { + "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", + "type": "string" + }, + "type": { + "description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".", + "type": "string" + } + } + }, "codersdk.AIBridgeProxyConfig": { "type": "object", "properties": { diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 614774876d..ffbae26413 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -1361,7 +1361,7 @@ func TestListChatModels(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) values := chatDeploymentValues(t) - values.AI.BridgeConfig.OpenAI.Key = serpent.String("deployment-openai-key") + values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key") client := newChatClientWithDeploymentValues(t, values) _ = coderdtest.CreateFirstUser(t, client.Client) @@ -1749,7 +1749,7 @@ func TestListChatProviders(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) values := chatDeploymentValues(t) - values.AI.BridgeConfig.OpenAI.Key = serpent.String("deployment-openai-key") + values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key") client := newChatClientWithDeploymentValues(t, values) _ = coderdtest.CreateFirstUser(t, client.Client) @@ -1903,7 +1903,7 @@ func TestCreateChatProvider(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) values := chatDeploymentValues(t) - values.AI.BridgeConfig.OpenAI.Key = serpent.String("deployment-openai-key") + values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key") client := newChatClientWithDeploymentValues(t, values) _ = coderdtest.CreateFirstUser(t, client.Client) @@ -2105,7 +2105,7 @@ func TestUpdateChatProvider(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) values := chatDeploymentValues(t) - values.AI.BridgeConfig.OpenAI.Key = serpent.String("deployment-openai-key") + values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key") client := newChatClientWithDeploymentValues(t, values) _ = coderdtest.CreateFirstUser(t, client.Client) @@ -2341,9 +2341,9 @@ func TestChatProviderAPIKeysFromDeploymentValues(t *testing.T) { t.Parallel() values := chatDeploymentValues(t) - values.AI.BridgeConfig.OpenAI.Key = serpent.String("deployment-openai-key") - values.AI.BridgeConfig.Anthropic.Key = serpent.String("deployment-anthropic-key") - values.AI.BridgeConfig.OpenAI.BaseURL = serpent.String("https://custom-openai.example.com") + values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key") + values.AI.BridgeConfig.LegacyAnthropic.Key = serpent.String("deployment-anthropic-key") + values.AI.BridgeConfig.LegacyOpenAI.BaseURL = serpent.String("https://custom-openai.example.com") keys := coderd.ChatProviderAPIKeysFromDeploymentValues(values) require.Equal(t, chatprovider.ProviderAPIKeys{}, keys) @@ -2546,7 +2546,7 @@ func TestUserChatProviderConfigs(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) values := chatDeploymentValues(t) - values.AI.BridgeConfig.OpenAI.Key = serpent.String("deployment-openai-key") + values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key") client := newChatClientWithDeploymentValues(t, values) _ = coderdtest.CreateFirstUser(t, client.Client) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index aff273f041..ddf2a08c7f 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3650,7 +3650,7 @@ Write out the current server config as YAML to stdout.`, Description: "The base URL of the OpenAI API.", Flag: "aibridge-openai-base-url", Env: "CODER_AIBRIDGE_OPENAI_BASE_URL", - Value: &c.AI.BridgeConfig.OpenAI.BaseURL, + Value: &c.AI.BridgeConfig.LegacyOpenAI.BaseURL, Default: "https://api.openai.com/v1/", Group: &deploymentGroupAIBridge, YAML: "openai_base_url", @@ -3660,7 +3660,7 @@ Write out the current server config as YAML to stdout.`, Description: "The key to authenticate against the OpenAI API.", Flag: "aibridge-openai-key", Env: "CODER_AIBRIDGE_OPENAI_KEY", - Value: &c.AI.BridgeConfig.OpenAI.Key, + Value: &c.AI.BridgeConfig.LegacyOpenAI.Key, Default: "", Group: &deploymentGroupAIBridge, Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), @@ -3670,7 +3670,7 @@ Write out the current server config as YAML to stdout.`, Description: "The base URL of the Anthropic API.", Flag: "aibridge-anthropic-base-url", Env: "CODER_AIBRIDGE_ANTHROPIC_BASE_URL", - Value: &c.AI.BridgeConfig.Anthropic.BaseURL, + Value: &c.AI.BridgeConfig.LegacyAnthropic.BaseURL, Default: "https://api.anthropic.com/", Group: &deploymentGroupAIBridge, YAML: "anthropic_base_url", @@ -3680,7 +3680,7 @@ Write out the current server config as YAML to stdout.`, Description: "The key to authenticate against the Anthropic API.", Flag: "aibridge-anthropic-key", Env: "CODER_AIBRIDGE_ANTHROPIC_KEY", - Value: &c.AI.BridgeConfig.Anthropic.Key, + Value: &c.AI.BridgeConfig.LegacyAnthropic.Key, Default: "", Group: &deploymentGroupAIBridge, Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), @@ -3691,7 +3691,7 @@ Write out the current server config as YAML to stdout.`, "over CODER_AIBRIDGE_BEDROCK_REGION.", Flag: "aibridge-bedrock-base-url", Env: "CODER_AIBRIDGE_BEDROCK_BASE_URL", - Value: &c.AI.BridgeConfig.Bedrock.BaseURL, + Value: &c.AI.BridgeConfig.LegacyBedrock.BaseURL, Default: "", Group: &deploymentGroupAIBridge, YAML: "bedrock_base_url", @@ -3702,7 +3702,7 @@ Write out the current server config as YAML to stdout.`, "'https://bedrock-runtime..amazonaws.com'.", Flag: "aibridge-bedrock-region", Env: "CODER_AIBRIDGE_BEDROCK_REGION", - Value: &c.AI.BridgeConfig.Bedrock.Region, + Value: &c.AI.BridgeConfig.LegacyBedrock.Region, Default: "", Group: &deploymentGroupAIBridge, YAML: "bedrock_region", @@ -3712,7 +3712,7 @@ Write out the current server config as YAML to stdout.`, Description: "The access key to authenticate against the AWS Bedrock API.", Flag: "aibridge-bedrock-access-key", Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY", - Value: &c.AI.BridgeConfig.Bedrock.AccessKey, + Value: &c.AI.BridgeConfig.LegacyBedrock.AccessKey, Default: "", Group: &deploymentGroupAIBridge, Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), @@ -3722,7 +3722,7 @@ Write out the current server config as YAML to stdout.`, Description: "The access key secret to use with the access key to authenticate against the AWS Bedrock API.", Flag: "aibridge-bedrock-access-key-secret", Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY_SECRET", - Value: &c.AI.BridgeConfig.Bedrock.AccessKeySecret, + Value: &c.AI.BridgeConfig.LegacyBedrock.AccessKeySecret, Default: "", Group: &deploymentGroupAIBridge, Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), @@ -3732,7 +3732,7 @@ Write out the current server config as YAML to stdout.`, Description: "The model to use when making requests to the AWS Bedrock API.", Flag: "aibridge-bedrock-model", Env: "CODER_AIBRIDGE_BEDROCK_MODEL", - Value: &c.AI.BridgeConfig.Bedrock.Model, + Value: &c.AI.BridgeConfig.LegacyBedrock.Model, Default: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", // See https://docs.claude.com/en/api/claude-on-amazon-bedrock#accessing-bedrock. Group: &deploymentGroupAIBridge, YAML: "bedrock_model", @@ -3742,7 +3742,7 @@ Write out the current server config as YAML to stdout.`, Description: "The small fast model to use when making requests to the AWS Bedrock API. Claude Code uses Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables.", Flag: "aibridge-bedrock-small-fastmodel", Env: "CODER_AIBRIDGE_BEDROCK_SMALL_FAST_MODEL", - Value: &c.AI.BridgeConfig.Bedrock.SmallFastModel, + Value: &c.AI.BridgeConfig.LegacyBedrock.SmallFastModel, Default: "global.anthropic.claude-haiku-4-5-20251001-v1:0", // See https://docs.claude.com/en/api/claude-on-amazon-bedrock#accessing-bedrock. Group: &deploymentGroupAIBridge, YAML: "bedrock_small_fast_model", @@ -3940,17 +3940,15 @@ Write out the current server config as YAML to stdout.`, YAML: "key_file", }, { - Name: "AI Bridge Proxy Domain Allowlist", - Description: "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.", - Flag: "aibridge-proxy-domain-allowlist", - Env: "CODER_AIBRIDGE_PROXY_DOMAIN_ALLOWLIST", - Value: &c.AI.BridgeProxyConfig.DomainAllowlist, - Default: "api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,chatgpt.com", - Hidden: true, - Group: &deploymentGroupAIBridgeProxy, - YAML: "domain_allowlist", + Name: "AI Bridge Proxy Domain Allowlist", + Description: "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.", + Flag: "aibridge-proxy-domain-allowlist", + Env: "CODER_AIBRIDGE_PROXY_DOMAIN_ALLOWLIST", + Value: &c.AI.BridgeProxyConfig.DomainAllowlist, + Default: "", + Hidden: true, + Group: &deploymentGroupAIBridgeProxy, + YAML: "domain_allowlist", }, { Name: "AI Bridge Proxy Upstream Proxy", @@ -4047,10 +4045,16 @@ Write out the current server config as YAML to stdout.`, } type AIBridgeConfig struct { - Enabled serpent.Bool `json:"enabled" typescript:",notnull"` - OpenAI AIBridgeOpenAIConfig `json:"openai" typescript:",notnull"` - Anthropic AIBridgeAnthropicConfig `json:"anthropic" typescript:",notnull"` - Bedrock AIBridgeBedrockConfig `json:"bedrock" typescript:",notnull"` + Enabled serpent.Bool `json:"enabled" typescript:",notnull"` + // Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. + LegacyOpenAI AIBridgeOpenAIConfig `json:"openai" typescript:",notnull"` + // Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. + LegacyAnthropic AIBridgeAnthropicConfig `json:"anthropic" typescript:",notnull"` + // Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. + LegacyBedrock AIBridgeBedrockConfig `json:"bedrock" typescript:",notnull"` + // Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER__ + // env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above. + Providers []AIBridgeProviderConfig `json:"providers,omitempty"` // Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. InjectCoderMCPTools serpent.Bool `json:"inject_coder_mcp_tools" typescript:",notnull"` Retention serpent.Duration `json:"retention" typescript:",notnull"` @@ -4086,6 +4090,29 @@ type AIBridgeBedrockConfig struct { SmallFastModel serpent.String `json:"small_fast_model" typescript:",notnull"` } +// AIBridgeProviderConfig represents a single AI Bridge provider instance, +// parsed from CODER_AIBRIDGE_PROVIDER__ environment variables. +// This follows the same indexed pattern as ExternalAuthConfig. +type AIBridgeProviderConfig struct { + // Type is the provider type: "openai", "anthropic", or "copilot". + Type string `json:"type"` + // Name is the unique instance identifier used for routing. + // Defaults to Type if not provided. + Name string `json:"name"` + // Key is the API key for authenticating with the upstream provider. + Key string `json:"-"` + // BaseURL is the base URL of the upstream provider API. + BaseURL string `json:"base_url"` + + // Bedrock fields (only applicable when Type == "anthropic"). + BedrockBaseURL string `json:"-"` + BedrockRegion string `json:"bedrock_region,omitempty"` + BedrockAccessKey string `json:"-"` + BedrockAccessKeySecret string `json:"-"` + BedrockModel string `json:"bedrock_model,omitempty"` + BedrockSmallFastModel string `json:"bedrock_small_fast_model,omitempty"` +} + type AIBridgeProxyConfig struct { Enabled serpent.Bool `json:"enabled" typescript:",notnull"` ListenAddr serpent.String `json:"listen_addr" typescript:",notnull"` diff --git a/docs/ai-coder/ai-gateway/clients/copilot.md b/docs/ai-coder/ai-gateway/clients/copilot.md index 1d5ae52512..9aaf863ca3 100644 --- a/docs/ai-coder/ai-gateway/clients/copilot.md +++ b/docs/ai-coder/ai-gateway/clients/copilot.md @@ -12,6 +12,33 @@ For general information about GitHub Copilot, see the [GitHub Copilot documentat For general client configuration requirements, see [AI Gateway Proxy Client Configuration](../ai-gateway-proxy/setup.md#client-configuration). The sections below cover Copilot-specific setup for each client. +## Provider configuration + +Configure a `copilot` provider using the +[indexed provider format](../setup.md#multiple-instances-of-the-same-provider). +Copilot providers use OAuth app installations for authentication rather than +static API keys. + +```sh +# GitHub Copilot (Individual) +export CODER_AIBRIDGE_PROVIDER_0_TYPE=copilot +export CODER_AIBRIDGE_PROVIDER_0_NAME=copilot + +# GitHub Copilot Business +export CODER_AIBRIDGE_PROVIDER_1_TYPE=copilot +export CODER_AIBRIDGE_PROVIDER_1_NAME=copilot-business +export CODER_AIBRIDGE_PROVIDER_1_BASE_URL=https://api.business.githubcopilot.com + +# GitHub Copilot Enterprise +export CODER_AIBRIDGE_PROVIDER_2_TYPE=copilot +export CODER_AIBRIDGE_PROVIDER_2_NAME=copilot-enterprise +export CODER_AIBRIDGE_PROVIDER_2_BASE_URL=https://api.enterprise.githubcopilot.com +``` + +The default base URL targets the individual Copilot API +(`api.individual.githubcopilot.com`). Override `BASE_URL` for Business or +Enterprise tiers as shown above. + ## Copilot CLI For installation instructions, see [GitHub Copilot CLI documentation](https://docs.github.com/en/copilot/how-tos/copilot-cli/install-copilot-cli). diff --git a/docs/ai-coder/ai-gateway/setup.md b/docs/ai-coder/ai-gateway/setup.md index 3d37910916..62db3115c9 100644 --- a/docs/ai-coder/ai-gateway/setup.md +++ b/docs/ai-coder/ai-gateway/setup.md @@ -92,15 +92,102 @@ proxy between AI Gateway and AWS Bedrock. coder server ``` -### Additional providers and Model Proxies +### GitHub Copilot -AI Gateway can relay traffic to other OpenAI- or Anthropic-compatible services or model proxies like LiteLLM by pointing the base URL variables above at the provider you operate. Share feedback or follow along in the [`aibridge`](https://github.com/coder/aibridge) issue tracker as we expand support for additional providers. +Configure a `copilot` provider using the +[indexed provider format](#multiple-instances-of-the-same-provider) with +separate instances for Individual, Business, and Enterprise tiers. + +See [GitHub Copilot — Provider configuration](./clients/copilot.md#provider-configuration) +for full setup instructions. + +### ChatGPT + +Configure a ChatGPT provider by creating an `openai`-typed instance with the +ChatGPT Codex base URL: + +```sh +export CODER_AIBRIDGE_PROVIDER_0_TYPE=openai +export CODER_AIBRIDGE_PROVIDER_0_NAME=chatgpt +export CODER_AIBRIDGE_PROVIDER_0_BASE_URL=https://chatgpt.com/backend-api/codex +``` > [!NOTE] > See the [Supported APIs](./reference.md#supported-apis) section below for precise endpoint coverage and interception behavior. +### Multiple instances of the same provider + +You can configure multiple instances of the same provider type — for example, to +route different teams to separate API keys, use different base URLs per region, or +connect to both a direct API and a proxy simultaneously. Use indexed environment +variables following the pattern `CODER_AIBRIDGE_PROVIDER__`: + +```sh +# Anthropic routed through a corporate proxy +export CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic +export CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-corp +export CODER_AIBRIDGE_PROVIDER_0_KEY=sk-ant-corp-xxx +export CODER_AIBRIDGE_PROVIDER_0_BASE_URL=https://llm-proxy.internal.example.com/anthropic + +# Anthropic direct (for teams that need direct access) +export CODER_AIBRIDGE_PROVIDER_1_TYPE=anthropic +export CODER_AIBRIDGE_PROVIDER_1_NAME=anthropic-direct +export CODER_AIBRIDGE_PROVIDER_1_KEY=sk-ant-direct-yyy + +# Azure-hosted OpenAI deployment +export CODER_AIBRIDGE_PROVIDER_2_TYPE=openai +export CODER_AIBRIDGE_PROVIDER_2_NAME=azure-openai +export CODER_AIBRIDGE_PROVIDER_2_KEY=azure-key-zzz +export CODER_AIBRIDGE_PROVIDER_2_BASE_URL=https://my-deployment.openai.azure.com/ + +# Anthropic via AWS Bedrock +export CODER_AIBRIDGE_PROVIDER_3_TYPE=anthropic +export CODER_AIBRIDGE_PROVIDER_3_NAME=anthropic-bedrock +export CODER_AIBRIDGE_PROVIDER_3_BEDROCK_REGION=us-west-2 +export CODER_AIBRIDGE_PROVIDER_3_BEDROCK_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE +export CODER_AIBRIDGE_PROVIDER_3_BEDROCK_ACCESS_KEY_SECRET=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +coder server +``` + +Each provider instance gets a unique route based on its `NAME`. Clients send +requests to `/api/v2/aibridge//` to target a specific instance: + +| Instance name | Route | +|---------------------|-----------------------------------------------------| +| `anthropic-corp` | `/api/v2/aibridge/anthropic-corp/v1/messages` | +| `anthropic-direct` | `/api/v2/aibridge/anthropic-direct/v1/messages` | +| `azure-openai` | `/api/v2/aibridge/azure-openai/v1/chat/completions` | +| `anthropic-bedrock` | `/api/v2/aibridge/anthropic-bedrock/v1/messages` | + +**Supported keys per provider:** + +| Key | Required | Description | +|------------|----------|------------------------------------------------------| +| `TYPE` | Yes | Provider type: `openai`, `anthropic`, or `copilot` | +| `NAME` | No | Unique instance name for routing. Defaults to `TYPE` | +| `KEY` | No | API key for upstream authentication (alias: `KEYS`) | +| `BASE_URL` | No | Base URL of the upstream API | + +For `anthropic` providers using AWS Bedrock, the following keys are also +available: `BEDROCK_BASE_URL`, `BEDROCK_REGION`, +`BEDROCK_ACCESS_KEY` (alias: `BEDROCK_ACCESS_KEYS`), +`BEDROCK_ACCESS_KEY_SECRET` (alias: `BEDROCK_ACCESS_KEY_SECRETS`), +`BEDROCK_MODEL`, `BEDROCK_SMALL_FAST_MODEL`. + +> [!NOTE] +> Indices must be contiguous and start at `0`. Each instance must have a unique +> `NAME` — if two instances of the same `TYPE` omit `NAME`, they will both +> default to the type name and fail with a duplicate name error. +> +> The legacy single-provider environment variables (`CODER_AIBRIDGE_OPENAI_KEY`, +> `CODER_AIBRIDGE_ANTHROPIC_KEY`, etc.) continue to work. However, setting both +> a legacy variable and an indexed provider with the same default name (e.g. +> `CODER_AIBRIDGE_OPENAI_KEY` and an indexed provider named `openai`) will +> produce a startup error — remove one or the other to resolve the conflict. + ## Data Retention AI Gateway records prompts, token usage, tool invocations, and model reasoning for auditing and diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 90307b8530..7d2660395e 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -203,6 +203,16 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "base_url": "string", "key": "string" }, + "providers": [ + { + "base_url": "string", + "bedrock_model": "string", + "bedrock_region": "string", + "bedrock_small_fast_model": "string", + "name": "string", + "type": "string" + } + ], "rate_limit": 0, "retention": 0, "send_actor_headers": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 78901d2026..e97796d2ee 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -455,6 +455,16 @@ "base_url": "string", "key": "string" }, + "providers": [ + { + "base_url": "string", + "bedrock_model": "string", + "bedrock_region": "string", + "bedrock_small_fast_model": "string", + "name": "string", + "type": "string" + } + ], "rate_limit": 0, "retention": 0, "send_actor_headers": true, @@ -464,23 +474,24 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------------------|----------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------| -| `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | | -| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | | -| `circuit_breaker_enabled` | boolean | false | | Circuit breaker protects against cascading failures from upstream AI provider rate limits (429, 503, 529 overloaded). | -| `circuit_breaker_failure_threshold` | integer | false | | | -| `circuit_breaker_interval` | integer | false | | | -| `circuit_breaker_max_requests` | integer | false | | | -| `circuit_breaker_timeout` | integer | false | | | -| `enabled` | boolean | false | | | -| `inject_coder_mcp_tools` | boolean | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. | -| `max_concurrency` | integer | false | | | -| `openai` | [codersdk.AIBridgeOpenAIConfig](#codersdkaibridgeopenaiconfig) | false | | | -| `rate_limit` | integer | false | | | -| `retention` | integer | false | | | -| `send_actor_headers` | boolean | false | | | -| `structured_logging` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------------------|-----------------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | +| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | +| `circuit_breaker_enabled` | boolean | false | | Circuit breaker protects against cascading failures from upstream AI provider rate limits (429, 503, 529 overloaded). | +| `circuit_breaker_failure_threshold` | integer | false | | | +| `circuit_breaker_interval` | integer | false | | | +| `circuit_breaker_max_requests` | integer | false | | | +| `circuit_breaker_timeout` | integer | false | | | +| `enabled` | boolean | false | | | +| `inject_coder_mcp_tools` | boolean | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. | +| `max_concurrency` | integer | false | | | +| `openai` | [codersdk.AIBridgeOpenAIConfig](#codersdkaibridgeopenaiconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | +| `providers` | array of [codersdk.AIBridgeProviderConfig](#codersdkaibridgeproviderconfig) | false | | Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER__ env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above. | +| `rate_limit` | integer | false | | | +| `retention` | integer | false | | | +| `send_actor_headers` | boolean | false | | | +| `structured_logging` | boolean | false | | | ## codersdk.AIBridgeInterception @@ -732,6 +743,30 @@ | `base_url` | string | false | | | | `key` | string | false | | | +## codersdk.AIBridgeProviderConfig + +```json +{ + "base_url": "string", + "bedrock_model": "string", + "bedrock_region": "string", + "bedrock_small_fast_model": "string", + "name": "string", + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|--------|----------|--------------|--------------------------------------------------------------------------------------------| +| `base_url` | string | false | | Base URL is the base URL of the upstream provider API. | +| `bedrock_model` | string | false | | | +| `bedrock_region` | string | false | | | +| `bedrock_small_fast_model` | string | false | | | +| `name` | string | false | | Name is the unique instance identifier used for routing. Defaults to Type if not provided. | +| `type` | string | false | | Type is the provider type: "openai", "anthropic", or "copilot". | + ## codersdk.AIBridgeProxyConfig ```json @@ -1234,6 +1269,16 @@ "base_url": "string", "key": "string" }, + "providers": [ + { + "base_url": "string", + "bedrock_model": "string", + "bedrock_region": "string", + "bedrock_small_fast_model": "string", + "name": "string", + "type": "string" + } + ], "rate_limit": 0, "retention": 0, "send_actor_headers": true, @@ -3258,6 +3303,16 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "base_url": "string", "key": "string" }, + "providers": [ + { + "base_url": "string", + "bedrock_model": "string", + "bedrock_region": "string", + "bedrock_small_fast_model": "string", + "name": "string", + "type": "string" + } + ], "rate_limit": 0, "retention": 0, "send_actor_headers": true, @@ -3837,6 +3892,16 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "base_url": "string", "key": "string" }, + "providers": [ + { + "base_url": "string", + "bedrock_model": "string", + "bedrock_region": "string", + "bedrock_small_fast_model": "string", + "name": "string", + "type": "string" + } + ], "rate_limit": 0, "retention": 0, "send_actor_headers": true, diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 079c617bb2..58594d6163 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -2093,6 +2093,64 @@ func TestUpstreamProxy(t *testing.T) { } } +// TestProxy_MITM_CustomProvider verifies that a non-builtin provider +// (e.g. OpenRouter) whose domain is added to the allowlist is correctly +// MITM'd and routed through the proxy to the bridge endpoint. +func TestProxy_MITM_CustomProvider(t *testing.T) { + t.Parallel() + + const ( + openrouterDomain = "openrouter.ai" + openrouterProvider = "openrouter" + ) + + // Track what aibridged receives. + var receivedPath, receivedBYOK string + + // Create a mock aibridged server that captures requests. + aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + receivedBYOK = r.Header.Get(agplaibridge.HeaderCoderToken) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello from aibridged")) + })) + t.Cleanup(aibridgedServer.Close) + + // Wire the custom domain and provider mapping directly, as the + // real daemon would after calling domainsFromProviders. + srv := newTestProxy(t, + withCoderAccessURL(aibridgedServer.URL), + withDomainAllowlist(openrouterDomain), + withAIBridgeProviderFromHost(func(host string) string { + if host == openrouterDomain { + return openrouterProvider + } + return "" + }), + ) + + certPool := getProxyCertPool(t) + client := newProxyClient(t, srv, makeProxyAuthHeader("coder-token"), certPool, false) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://"+openrouterDomain+"/api/v1/chat/completions", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer user-llm-token") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "hello from aibridged", string(body)) + + // The proxy should route through the aibridge path using the custom + // provider name. + require.Equal(t, "/api/v2/aibridge/"+openrouterProvider+"/api/v1/chat/completions", receivedPath) + require.Equal(t, "coder-token", receivedBYOK) +} + func TestProxy_PrivateIPBlocking(t *testing.T) { t.Parallel() diff --git a/enterprise/cli/aibridged.go b/enterprise/cli/aibridged.go index 7c9952c901..3c03e0b18b 100644 --- a/enterprise/cli/aibridged.go +++ b/enterprise/cli/aibridged.go @@ -10,68 +10,17 @@ import ( "github.com/coder/aibridge" "github.com/coder/aibridge/config" - agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/aibridged" "github.com/coder/coder/v2/enterprise/coderd" ) -func newAIBridgeDaemon(coderAPI *coderd.API) (*aibridged.Server, error) { +func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider) (*aibridged.Server, error) { ctx := context.Background() coderAPI.Logger.Debug(ctx, "starting in-memory aibridge daemon") logger := coderAPI.Logger.Named("aibridged") - cfg := coderAPI.DeploymentValues.AI.BridgeConfig - - // Build circuit breaker config if enabled. - var cbConfig *config.CircuitBreaker - if cfg.CircuitBreakerEnabled.Value() { - cbConfig = &config.CircuitBreaker{ - FailureThreshold: uint32(cfg.CircuitBreakerFailureThreshold.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. - Interval: cfg.CircuitBreakerInterval.Value(), - Timeout: cfg.CircuitBreakerTimeout.Value(), - MaxRequests: uint32(cfg.CircuitBreakerMaxRequests.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. - } - } - - // Setup supported providers with circuit breaker config. - providers := []aibridge.Provider{ - aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ - Name: aibridge.ProviderOpenAI, - BaseURL: cfg.OpenAI.BaseURL.String(), - Key: cfg.OpenAI.Key.String(), - CircuitBreaker: cbConfig, - SendActorHeaders: cfg.SendActorHeaders.Value(), - }), - aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{ - Name: aibridge.ProviderAnthropic, - BaseURL: cfg.Anthropic.BaseURL.String(), - Key: cfg.Anthropic.Key.String(), - CircuitBreaker: cbConfig, - SendActorHeaders: cfg.SendActorHeaders.Value(), - }, getBedrockConfig(cfg.Bedrock)), - aibridge.NewCopilotProvider(aibridge.CopilotConfig{ - Name: aibridge.ProviderCopilot, - CircuitBreaker: cbConfig, - }), - aibridge.NewCopilotProvider(aibridge.CopilotConfig{ - Name: agplaibridge.ProviderCopilotBusiness, - BaseURL: "https://" + agplaibridge.HostCopilotBusiness, - CircuitBreaker: cbConfig, - }), - aibridge.NewCopilotProvider(aibridge.CopilotConfig{ - Name: agplaibridge.ProviderCopilotEnterprise, - BaseURL: "https://" + agplaibridge.HostCopilotEnterprise, - CircuitBreaker: cbConfig, - }), - aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ - Name: agplaibridge.ProviderChatGPT, - BaseURL: agplaibridge.BaseURLChatGPT, - CircuitBreaker: cbConfig, - SendActorHeaders: cfg.SendActorHeaders.Value(), - }), - } reg := prometheus.WrapRegistererWithPrefix("coder_aibridged_", coderAPI.PrometheusRegistry) metrics := aibridge.NewMetrics(reg) @@ -93,6 +42,122 @@ func newAIBridgeDaemon(coderAPI *coderd.API) (*aibridged.Server, error) { return srv, nil } +// buildProviders constructs the list of aibridge providers from config. +// It merges legacy single-provider env vars and indexed provider configs: +// 1. Legacy providers (from CODER_AIBRIDGE_OPENAI_KEY, etc.) are added first. +// If a legacy name conflicts with an indexed provider, startup fails with +// a clear error asking the admin to remove one or the other. +// 2. Indexed providers (from CODER_AIBRIDGE_PROVIDER__*) are added next. +func buildProviders(cfg codersdk.AIBridgeConfig) ([]aibridge.Provider, error) { + var cbConfig *config.CircuitBreaker + if cfg.CircuitBreakerEnabled.Value() { + cbConfig = &config.CircuitBreaker{ + FailureThreshold: uint32(cfg.CircuitBreakerFailureThreshold.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. + Interval: cfg.CircuitBreakerInterval.Value(), + Timeout: cfg.CircuitBreakerTimeout.Value(), + MaxRequests: uint32(cfg.CircuitBreakerMaxRequests.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. + } + } + + var providers []aibridge.Provider + usedNames := make(map[string]struct{}) + + // Collect names from indexed providers so we can detect conflicts + // with legacy providers. + for _, p := range cfg.Providers { + name := p.Name + if name == "" { + name = p.Type + } + usedNames[name] = struct{}{} + } + + // Add legacy OpenAI provider if configured. + if cfg.LegacyOpenAI.Key.String() != "" { + if _, conflict := usedNames[aibridge.ProviderOpenAI]; conflict { + return nil, xerrors.Errorf("legacy CODER_AIBRIDGE_OPENAI_KEY conflicts with indexed provider named %q; remove one or the other", aibridge.ProviderOpenAI) + } + providers = append(providers, aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ + Name: aibridge.ProviderOpenAI, + BaseURL: cfg.LegacyOpenAI.BaseURL.String(), + Key: cfg.LegacyOpenAI.Key.String(), + CircuitBreaker: cbConfig, + SendActorHeaders: cfg.SendActorHeaders.Value(), + })) + usedNames[aibridge.ProviderOpenAI] = struct{}{} + } + + // Add legacy Anthropic provider if configured. Bedrock credentials + // alone are sufficient — an Anthropic API key is not required when + // using AWS Bedrock. + if cfg.LegacyAnthropic.Key.String() != "" || getBedrockConfig(cfg.LegacyBedrock) != nil { + if _, conflict := usedNames[aibridge.ProviderAnthropic]; conflict { + return nil, xerrors.Errorf("legacy CODER_AIBRIDGE_ANTHROPIC_KEY conflicts with indexed provider named %q; remove one or the other", aibridge.ProviderAnthropic) + } + providers = append(providers, aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{ + Name: aibridge.ProviderAnthropic, + BaseURL: cfg.LegacyAnthropic.BaseURL.String(), + Key: cfg.LegacyAnthropic.Key.String(), + CircuitBreaker: cbConfig, + SendActorHeaders: cfg.SendActorHeaders.Value(), + }, getBedrockConfig(cfg.LegacyBedrock))) + usedNames[aibridge.ProviderAnthropic] = struct{}{} + } + + // Add indexed providers. + for _, p := range cfg.Providers { + name := p.Name + if name == "" { + name = p.Type + } + switch p.Type { + case aibridge.ProviderOpenAI: + providers = append(providers, aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ + Name: name, + BaseURL: p.BaseURL, + Key: p.Key, + CircuitBreaker: cbConfig, + SendActorHeaders: cfg.SendActorHeaders.Value(), + })) + case aibridge.ProviderAnthropic: + providers = append(providers, aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{ + Name: name, + BaseURL: p.BaseURL, + Key: p.Key, + CircuitBreaker: cbConfig, + SendActorHeaders: cfg.SendActorHeaders.Value(), + }, bedrockConfigFromProvider(p))) + case aibridge.ProviderCopilot: + providers = append(providers, aibridge.NewCopilotProvider(aibridge.CopilotConfig{ + Name: name, + BaseURL: p.BaseURL, + CircuitBreaker: cbConfig, + })) + default: + return nil, xerrors.Errorf("unknown provider type %q for provider %q", p.Type, name) + } + } + + return providers, nil +} + +// bedrockConfigFromProvider converts Bedrock fields from an indexed +// AIBridgeProviderConfig into an aibridge AWSBedrockConfig. +// Returns nil if no Bedrock fields are set. +func bedrockConfigFromProvider(p codersdk.AIBridgeProviderConfig) *aibridge.AWSBedrockConfig { + if p.BedrockRegion == "" && p.BedrockBaseURL == "" && p.BedrockAccessKey == "" && p.BedrockAccessKeySecret == "" { + return nil + } + return &aibridge.AWSBedrockConfig{ + BaseURL: p.BedrockBaseURL, + Region: p.BedrockRegion, + AccessKey: p.BedrockAccessKey, + AccessKeySecret: p.BedrockAccessKeySecret, + Model: p.BedrockModel, + SmallFastModel: p.BedrockSmallFastModel, + } +} + func getBedrockConfig(cfg codersdk.AIBridgeBedrockConfig) *aibridge.AWSBedrockConfig { if cfg.Region.String() == "" && cfg.BaseURL.String() == "" && cfg.AccessKey.String() == "" && cfg.AccessKeySecret.String() == "" { return nil diff --git a/enterprise/cli/aibridged_internal_test.go b/enterprise/cli/aibridged_internal_test.go new file mode 100644 index 0000000000..ec1f2e64fd --- /dev/null +++ b/enterprise/cli/aibridged_internal_test.go @@ -0,0 +1,269 @@ +//go:build !slim + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/aibridge" + agplaibridge "github.com/coder/coder/v2/coderd/aibridge" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func TestBuildProviders(t *testing.T) { + t.Parallel() + + t.Run("EmptyConfig", func(t *testing.T) { + t.Parallel() + providers, err := buildProviders(codersdk.AIBridgeConfig{}) + require.NoError(t, err) + assert.Empty(t, providers) + }) + + t.Run("LegacyOnly", func(t *testing.T) { + t.Parallel() + cfg := codersdk.AIBridgeConfig{} + cfg.LegacyOpenAI.Key = serpent.String("sk-openai") + cfg.LegacyAnthropic.Key = serpent.String("sk-anthropic") + + providers, err := buildProviders(cfg) + require.NoError(t, err) + + names := providerNames(providers) + assert.Contains(t, names, aibridge.ProviderOpenAI) + assert.Contains(t, names, aibridge.ProviderAnthropic) + assert.Len(t, names, 2) + }) + + t.Run("IndexedOnly", func(t *testing.T) { + t.Parallel() + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderAnthropic, Name: "anthropic-zdr", Key: "sk-zdr"}, + {Type: aibridge.ProviderOpenAI, Name: "openai-azure", Key: "sk-azure", BaseURL: "https://azure.openai.com"}, + }, + } + + providers, err := buildProviders(cfg) + require.NoError(t, err) + + names := providerNames(providers) + assert.Equal(t, []string{"anthropic-zdr", "openai-azure"}, names) + }) + + t.Run("LegacyOpenAIConflictsWithIndexed", func(t *testing.T) { + t.Parallel() + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Key: "sk-indexed"}, + }, + } + cfg.LegacyOpenAI.Key = serpent.String("sk-legacy") + + _, err := buildProviders(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts with indexed provider") + }) + + t.Run("LegacyAnthropicConflictsWithIndexed", func(t *testing.T) { + t.Parallel() + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderAnthropic, Name: aibridge.ProviderAnthropic, Key: "sk-indexed"}, + }, + } + cfg.LegacyAnthropic.Key = serpent.String("sk-legacy") + + _, err := buildProviders(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts with indexed provider") + }) + + t.Run("MixedLegacyAndIndexed", func(t *testing.T) { + t.Parallel() + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderAnthropic, Name: "anthropic-zdr", Key: "sk-zdr"}, + }, + } + cfg.LegacyOpenAI.Key = serpent.String("sk-openai") + cfg.LegacyAnthropic.Key = serpent.String("sk-anthropic") + + providers, err := buildProviders(cfg) + require.NoError(t, err) + + names := providerNames(providers) + assert.Contains(t, names, aibridge.ProviderOpenAI) + assert.Contains(t, names, aibridge.ProviderAnthropic) + assert.Contains(t, names, "anthropic-zdr") + }) + + t.Run("LegacyAnthropicWithBedrock", func(t *testing.T) { + t.Parallel() + cfg := codersdk.AIBridgeConfig{} + cfg.LegacyAnthropic.Key = serpent.String("sk-anthropic") + cfg.LegacyBedrock.Region = serpent.String("us-west-2") + cfg.LegacyBedrock.AccessKey = serpent.String("AKID") + cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret") + + providers, err := buildProviders(cfg) + require.NoError(t, err) + + names := providerNames(providers) + assert.Equal(t, []string{aibridge.ProviderAnthropic}, 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. + cfg := codersdk.AIBridgeConfig{} + cfg.LegacyBedrock.Region = serpent.String("us-west-2") + cfg.LegacyBedrock.AccessKey = serpent.String("AKID") + cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret") + + providers, err := buildProviders(cfg) + require.NoError(t, err) + require.Len(t, providers, 1) + + p := providers[0] + assert.Equal(t, aibridge.ProviderAnthropic, p.Type()) + assert.Equal(t, aibridge.ProviderAnthropic, p.Name()) + }) + + t.Run("UnknownType", func(t *testing.T) { + t.Parallel() + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: "gemini", Name: "gemini-pro"}, + }, + } + + _, err := buildProviders(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown provider type") + }) + + t.Run("CopilotVariants", func(t *testing.T) { + t.Parallel() + // Copilot providers can target any of the three GitHub + // Copilot API hosts via an explicit BASE_URL. + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderCopilot, Name: aibridge.ProviderCopilot}, + {Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotBusiness, BaseURL: "https://" + agplaibridge.HostCopilotBusiness}, + {Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotEnterprise, BaseURL: "https://" + agplaibridge.HostCopilotEnterprise}, + }, + } + + providers, err := buildProviders(cfg) + require.NoError(t, err) + require.Len(t, providers, 3) + + assert.Equal(t, aibridge.ProviderCopilot, providers[0].Name()) + assert.Equal(t, agplaibridge.ProviderCopilotBusiness, providers[1].Name()) + assert.Equal(t, "https://"+agplaibridge.HostCopilotBusiness, providers[1].BaseURL()) + assert.Equal(t, agplaibridge.ProviderCopilotEnterprise, providers[2].Name()) + assert.Equal(t, "https://"+agplaibridge.HostCopilotEnterprise, providers[2].BaseURL()) + }) + + t.Run("ChatGPTProvider", func(t *testing.T) { + t.Parallel() + // ChatGPT is an OpenAI-compatible provider with a custom + // base URL. Admins configure it as an indexed openai provider. + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderOpenAI, Name: agplaibridge.ProviderChatGPT, BaseURL: agplaibridge.BaseURLChatGPT}, + }, + } + + providers, err := buildProviders(cfg) + require.NoError(t, err) + require.Len(t, providers, 1) + + assert.Equal(t, agplaibridge.ProviderChatGPT, providers[0].Name()) + assert.Equal(t, agplaibridge.BaseURLChatGPT, providers[0].BaseURL()) + }) +} + +func providerNames(providers []aibridge.Provider) []string { + names := make([]string, len(providers)) + for i, p := range providers { + names[i] = p.Name() + } + return names +} + +func TestDomainsFromProviders(t *testing.T) { + t.Parallel() + + t.Run("ExtractsHostnames", func(t *testing.T) { + t.Parallel() + + providers, err := buildProviders(codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderOpenAI, Name: "openai", Key: "k"}, + {Type: aibridge.ProviderAnthropic, Name: "anthropic", Key: "k"}, + {Type: aibridge.ProviderOpenAI, Name: "custom", Key: "k", BaseURL: "https://custom-llm.example.com:8443/api"}, + }, + }) + require.NoError(t, err) + + domains, mapping := domainsFromProviders(providers) + + assert.Contains(t, domains, "api.openai.com") + assert.Contains(t, domains, "api.anthropic.com") + assert.Contains(t, domains, "custom-llm.example.com") + + assert.Equal(t, "openai", mapping("api.openai.com")) + assert.Equal(t, "anthropic", mapping("api.anthropic.com")) + assert.Equal(t, "custom", mapping("custom-llm.example.com")) + assert.Empty(t, mapping("unknown.com")) + }) + + t.Run("DeduplicatesSameHost", func(t *testing.T) { + t.Parallel() + + providers, err := buildProviders(codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderOpenAI, Name: "first", Key: "k", BaseURL: "https://api.example.com/v1"}, + {Type: aibridge.ProviderOpenAI, Name: "second", Key: "k", BaseURL: "https://api.example.com/v2"}, + }, + }) + require.NoError(t, err) + + domains, mapping := domainsFromProviders(providers) + + // Count occurrences of api.example.com. + count := 0 + for _, d := range domains { + if d == "api.example.com" { + count++ + } + } + assert.Equal(t, 1, count) + // First provider wins. + assert.Equal(t, "first", mapping("api.example.com")) + }) + + t.Run("CaseInsensitive", func(t *testing.T) { + t.Parallel() + + providers, err := buildProviders(codersdk.AIBridgeConfig{ + Providers: []codersdk.AIBridgeProviderConfig{ + {Type: aibridge.ProviderOpenAI, Name: "provider", Key: "k", BaseURL: "https://API.Example.COM/v1"}, + }, + }) + require.NoError(t, err) + + domains, mapping := domainsFromProviders(providers) + + assert.Contains(t, domains, "api.example.com") + assert.Equal(t, "provider", mapping("API.Example.COM")) + assert.Equal(t, "provider", mapping("api.example.com")) + }) +} diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 4dc0a41113..fb96e541a9 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -4,35 +4,41 @@ package cli import ( "context" + "net/url" + "strings" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" + "github.com/coder/aibridge" "github.com/coder/coder/v2/enterprise/aibridgeproxyd" "github.com/coder/coder/v2/enterprise/coderd" ) -func newAIBridgeProxyDaemon(coderAPI *coderd.API) (*aibridgeproxyd.Server, error) { +func newAIBridgeProxyDaemon(coderAPI *coderd.API, providers []aibridge.Provider) (*aibridgeproxyd.Server, error) { ctx := context.Background() coderAPI.Logger.Debug(ctx, "starting in-memory aibridgeproxy daemon") logger := coderAPI.Logger.Named("aibridgeproxyd") + domains, providerFromHost := domainsFromProviders(providers) + reg := prometheus.WrapRegistererWithPrefix("coder_aibridgeproxyd_", coderAPI.PrometheusRegistry) metrics := aibridgeproxyd.NewMetrics(reg) srv, err := aibridgeproxyd.New(ctx, logger, aibridgeproxyd.Options{ - ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(), - TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(), - TLSKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSKeyFile.String(), - CoderAccessURL: coderAPI.AccessURL.String(), - MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(), - MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(), - DomainAllowlist: coderAPI.DeploymentValues.AI.BridgeProxyConfig.DomainAllowlist.Value(), - UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(), - UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(), - AllowedPrivateCIDRs: coderAPI.DeploymentValues.AI.BridgeProxyConfig.AllowedPrivateCIDRs.Value(), - Metrics: metrics, + ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(), + TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(), + TLSKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSKeyFile.String(), + CoderAccessURL: coderAPI.AccessURL.String(), + MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(), + MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(), + DomainAllowlist: domains, + AIBridgeProviderFromHost: providerFromHost, + UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(), + UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(), + AllowedPrivateCIDRs: coderAPI.DeploymentValues.AI.BridgeProxyConfig.AllowedPrivateCIDRs.Value(), + Metrics: metrics, }) if err != nil { return nil, xerrors.Errorf("failed to start in-memory aibridgeproxy daemon: %w", err) @@ -40,3 +46,35 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (*aibridgeproxyd.Server, error return srv, nil } + +// domainsFromProviders extracts distinct hostnames from providers' base +// URLs and builds a host-to-provider-name mapping function. The returned +// domain list is suitable for use as DomainAllowlist and the mapping +// function is suitable for use as AIBridgeProviderFromHost. +func domainsFromProviders(providers []aibridge.Provider) ([]string, func(string) string) { + hostToProvider := make(map[string]string, len(providers)) + var domains []string + for _, p := range providers { + raw := p.BaseURL() + if raw == "" { + continue + } + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + continue + } + host := strings.ToLower(u.Hostname()) + if _, exists := hostToProvider[host]; exists { + // First provider wins; duplicates are expected when + // multiple providers share a base URL host (e.g. two + // OpenAI providers using the same proxy). + continue + } + hostToProvider[host] = p.Name() + domains = append(domains, host) + } + + return domains, func(host string) string { + return hostToProvider[strings.ToLower(host)] + } +} diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 4a51912f69..3b5df42a7d 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -18,7 +18,6 @@ import ( agplcoderd "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/cryptorand" - "github.com/coder/coder/v2/enterprise/aibridged" "github.com/coder/coder/v2/enterprise/audit" "github.com/coder/coder/v2/enterprise/audit/backends" "github.com/coder/coder/v2/enterprise/coderd" @@ -162,38 +161,49 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { usageCron.Start(ctx) closers.Add(usageCron) - // In-memory aibridge daemon. - // TODO(@deansheather): the lifecycle of the aibridged server is - // probably better managed by the enterprise API type itself. Managing - // it in the API type means we can avoid starting it up when the license - // is not entitled to the feature. - var aibridgeDaemon *aibridged.Server - if options.DeploymentValues.AI.BridgeConfig.Enabled { - aibridgeDaemon, err = newAIBridgeDaemon(api) + // Build the provider list and start AI Bridge daemons only when + // at least one of the bridge or proxy features is enabled. + bridgeEnabled := options.DeploymentValues.AI.BridgeConfig.Enabled.Value() + proxyEnabled := options.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() + if bridgeEnabled || proxyEnabled { + providers, err := buildProviders(options.DeploymentValues.AI.BridgeConfig) if err != nil { - return nil, nil, xerrors.Errorf("create aibridged: %w", err) + return nil, nil, xerrors.Errorf("build aibridge providers: %w", err) } - api.RegisterInMemoryAIBridgedHTTPHandler(aibridgeDaemon) + // In-memory aibridge daemon. + // TODO(@deansheather): the lifecycle of the aibridged server is + // probably better managed by the enterprise API type itself. Managing + // it in the API type means we can avoid starting it up when the license + // is not entitled to the feature. + if bridgeEnabled { + aibridgeDaemon, err := newAIBridgeDaemon(api, providers) + if err != nil { + return nil, nil, xerrors.Errorf("create aibridged: %w", err) + } - // When running as an in-memory daemon, the HTTP handler is wired into the - // coderd API and therefore is subject to its context. Calling Close() on - // aibridged will NOT affect in-flight requests but those will be closed once - // the API server is itself shutdown. - closers.Add(aibridgeDaemon) - } + api.RegisterInMemoryAIBridgedHTTPHandler(aibridgeDaemon) - // In-memory AI Bridge Proxy daemon - if options.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() { - aiBridgeProxyServer, err := newAIBridgeProxyDaemon(api) - if err != nil { - _ = closers.Close() - return nil, nil, xerrors.Errorf("create aibridgeproxyd: %w", err) + // When running as an in-memory daemon, the HTTP handler is + // wired into the coderd API and therefore is subject to its + // context. Calling Close() on aibridged will NOT affect + // in-flight requests but those will be closed once the API + // server is itself shutdown. + closers.Add(aibridgeDaemon) } - closers.Add(aiBridgeProxyServer) - // Register the handler so coderd can serve the proxy endpoints. - api.RegisterInMemoryAIBridgeProxydHTTPHandler(aiBridgeProxyServer.Handler()) + // In-memory AI Bridge Proxy daemon. + if proxyEnabled { + aiBridgeProxyServer, err := newAIBridgeProxyDaemon(api, providers) + if err != nil { + _ = closers.Close() + return nil, nil, xerrors.Errorf("create aibridgeproxyd: %w", err) + } + closers.Add(aiBridgeProxyServer) + + // Register the handler so coderd can serve the proxy endpoints. + api.RegisterInMemoryAIBridgeProxydHTTPHandler(aiBridgeProxyServer.Handler()) + } } return api.AGPL, closers, nil diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cb9f56561e..4e7582e0a7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -41,9 +41,23 @@ export interface AIBridgeBedrockConfig { // From codersdk/deployment.go export interface AIBridgeConfig { readonly enabled: boolean; + /** + * Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. + */ readonly openai: AIBridgeOpenAIConfig; + /** + * Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. + */ readonly anthropic: AIBridgeAnthropicConfig; + /** + * Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. + */ readonly bedrock: AIBridgeBedrockConfig; + /** + * Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER__ + * env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above. + */ + readonly providers?: readonly AIBridgeProviderConfig[]; /** * Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. */ @@ -109,6 +123,31 @@ export interface AIBridgeOpenAIConfig { readonly key: string; } +// From codersdk/deployment.go +/** + * AIBridgeProviderConfig represents a single AI Bridge provider instance, + * parsed from CODER_AIBRIDGE_PROVIDER__ environment variables. + * This follows the same indexed pattern as ExternalAuthConfig. + */ +export interface AIBridgeProviderConfig { + /** + * Type is the provider type: "openai", "anthropic", or "copilot". + */ + readonly type: string; + /** + * Name is the unique instance identifier used for routing. + * Defaults to Type if not provided. + */ + readonly name: string; + /** + * BaseURL is the base URL of the upstream provider API. + */ + readonly base_url: string; + readonly bedrock_region?: string; + readonly bedrock_model?: string; + readonly bedrock_small_fast_model?: string; +} + // From codersdk/deployment.go export interface AIBridgeProxyConfig { readonly enabled: boolean;