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
@@ -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()