mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+123
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+51
-3
@@ -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": {
|
||||
|
||||
Generated
+51
-3
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+52
-25
@@ -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.<region>.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_<N>_* env vars instead.
|
||||
LegacyOpenAI AIBridgeOpenAIConfig `json:"openai" typescript:",notnull"`
|
||||
// Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_<N>_* env vars instead.
|
||||
LegacyAnthropic AIBridgeAnthropicConfig `json:"anthropic" typescript:",notnull"`
|
||||
// Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_<N>_* env vars instead.
|
||||
LegacyBedrock AIBridgeBedrockConfig `json:"bedrock" typescript:",notnull"`
|
||||
// Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER_<N>_<KEY>
|
||||
// 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_<N>_<KEY> 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"`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
> [!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_<N>_<KEY>`:
|
||||
|
||||
```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/<NAME>/` 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
|
||||
|
||||
Generated
+10
@@ -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,
|
||||
|
||||
Generated
+82
-17
@@ -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_<N>_* env vars instead. |
|
||||
| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_<N>_* 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_<N>_* env vars instead. |
|
||||
| `providers` | array of [codersdk.AIBridgeProviderConfig](#codersdkaibridgeproviderconfig) | false | | Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER_<N>_<KEY> 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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+117
-52
@@ -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_<N>_*) 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
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
|
||||
+36
-26
@@ -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
|
||||
|
||||
Generated
+39
@@ -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_<N>_* env vars instead.
|
||||
*/
|
||||
readonly openai: AIBridgeOpenAIConfig;
|
||||
/**
|
||||
* Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_<N>_* env vars instead.
|
||||
*/
|
||||
readonly anthropic: AIBridgeAnthropicConfig;
|
||||
/**
|
||||
* Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER_<N>_* env vars instead.
|
||||
*/
|
||||
readonly bedrock: AIBridgeBedrockConfig;
|
||||
/**
|
||||
* Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER_<N>_<KEY>
|
||||
* 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_<N>_<KEY> 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;
|
||||
|
||||
Reference in New Issue
Block a user