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

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

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

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

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

---------

Signed-off-by: Danny Kopping <danny@coder.com>
This commit is contained in:
Danny Kopping
2026-04-15 07:59:37 +00:00
committed by GitHub
parent 730edba87a
commit 08045c2aac
17 changed files with 1326 additions and 163 deletions
+51 -3
View File
@@ -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": {
+51 -3
View File
@@ -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": {
+8 -8
View File
@@ -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)