feat: route extra ai_provider_types through OpenAI and Anthropic providers (#25722)

_Disclosure:_ _produced_ _with_ _Claude_ _Opus_ _4\.7_

AI Gateway only supports Anthropic (+Bedrock), OpenAI, and Copilot providers at present. All other types (Vercel, Gemini, etc) will be mapped to OpenAI since they support OpenAI-compatible endpoints.
This commit is contained in:
Danny Kopping
2026-05-27 16:16:05 +02:00
committed by GitHub
parent 6f06ace949
commit 2770bdc9d1
13 changed files with 243 additions and 64 deletions
+22 -4
View File
@@ -184,17 +184,28 @@ func buildAIProviderFromRow(
sendActorHeaders := cfg.SendActorHeaders.Value()
dumpDir := cfg.APIDumpDir.Value()
// aibridge currently has native support for OpenAI and Anthropic
// only. The other ai_provider_type values (azure, google,
// openai-compat, openrouter, vercel) route through the OpenAI
// provider because chatd configures them against their
// OpenAI-compatible endpoints. Bedrock routes through the Anthropic
// provider with a Bedrock discriminator in Settings.
switch row.Type {
case database.AiProviderTypeOpenai:
case database.AiProviderTypeOpenai,
database.AiProviderTypeAzure,
database.AiProviderTypeGoogle,
database.AiProviderTypeOpenaiCompat,
database.AiProviderTypeOpenrouter,
database.AiProviderTypeVercel:
if len(keys) == 0 && !cfg.AllowBYOK.Value() {
return nil, xerrors.New("openai provider has no api keys configured and BYOK is not enabled")
return nil, xerrors.Errorf("%s provider has no api keys configured and BYOK is not enabled", row.Type)
}
var pool *keypool.Pool
if len(keys) > 0 {
var err error
pool, err = buildAIProviderKeyPool(keys)
if err != nil {
return nil, xerrors.Errorf("openai key pool: %w", err)
return nil, xerrors.Errorf("%s key pool: %w", row.Type, err)
}
}
return aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
@@ -206,8 +217,15 @@ func buildAIProviderFromRow(
SendActorHeaders: sendActorHeaders,
}), nil
case database.AiProviderTypeAnthropic:
case database.AiProviderTypeAnthropic, database.AiProviderTypeBedrock:
bedrock := bedrockConfigFromRow(row, settings)
// A row typed 'bedrock' authenticates exclusively via settings;
// without populated Bedrock credentials it cannot make upstream
// calls, so refuse rather than falling back to an unsigned
// Anthropic client.
if row.Type == database.AiProviderTypeBedrock && bedrock == nil {
return nil, xerrors.New("bedrock provider has no bedrock credentials configured")
}
// Bedrock-backed Anthropic authenticates via AWS credentials in
// the settings blob, not the api_keys table. A bearer-token
// Anthropic without any key cannot make upstream calls.
+25 -12
View File
@@ -56,7 +56,6 @@ import (
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/cli/cliui"
@@ -3008,11 +3007,10 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
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)
providerType := database.AIProviderType(p.Type)
if !providerType.Valid() {
return nil, xerrors.Errorf("provider %d: unknown TYPE %q (must be one of: %v)",
i, p.Type, database.AllAIProviderTypeValues())
}
var bedrockKey, bedrockSecret string
@@ -3028,21 +3026,36 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
)
isBedrock := codersdk.IsBedrockConfigured(p.BedrockBaseURL, settings)
if p.Type != aibridge.ProviderAnthropic && isBedrock {
return nil, xerrors.Errorf("provider %d (%s): BEDROCK_* fields are only supported with TYPE %q",
i, p.Type, aibridge.ProviderAnthropic)
// BEDROCK_* fields are accepted on anthropic (mutually exclusive
// with KEYS) and required on bedrock. Any other TYPE rejecting
// them prevents silently-ignored credentials.
isBedrockType := providerType == database.AiProviderTypeBedrock
isAnthropicType := providerType == database.AiProviderTypeAnthropic
if !isAnthropicType && !isBedrockType && isBedrock {
return nil, xerrors.Errorf("provider %d (%s): BEDROCK_* fields are only supported with TYPE %q or %q",
i, p.Type, database.AiProviderTypeAnthropic, database.AiProviderTypeBedrock)
}
if p.Type == aibridge.ProviderCopilot && len(p.Keys) > 0 {
if isBedrockType && !isBedrock {
return nil, xerrors.Errorf("provider %d (%s): TYPE %q requires BEDROCK_* fields to be configured",
i, p.Type, database.AiProviderTypeBedrock)
}
if isBedrockType && len(p.Keys) > 0 {
return nil, xerrors.Errorf("provider %d (%s): KEY/KEYS are not supported for TYPE %q (use BEDROCK_* fields)",
i, p.Type, database.AiProviderTypeBedrock)
}
if providerType == database.AiProviderTypeCopilot && len(p.Keys) > 0 {
return nil, xerrors.Errorf("provider %d (%s): KEY/KEYS are not supported for TYPE %q",
i, p.Type, aibridge.ProviderCopilot)
i, p.Type, database.AiProviderTypeCopilot)
}
// An Anthropic provider authenticates either via a bearer
// token (KEYS) or via Bedrock (BEDROCK_*), not both. Surface
// the conflict here so misconfigured deployments fail before
// any DB work happens at server startup.
if p.Type == aibridge.ProviderAnthropic && len(p.Keys) > 0 && isBedrock {
if isAnthropicType && len(p.Keys) > 0 && isBedrock {
return nil, xerrors.Errorf("provider %d (%s): KEY/KEYS and BEDROCK_* fields are mutually exclusive",
i, p.Type)
}
+127 -2
View File
@@ -1,6 +1,8 @@
package cli
import (
"database/sql"
"encoding/json"
"fmt"
"testing"
@@ -11,6 +13,7 @@ import (
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
@@ -362,6 +365,40 @@ func TestReadAIProvidersFromEnv(t *testing.T) {
},
errContains: "cannot mix CODER_AIBRIDGE_PROVIDER_* and CODER_AI_GATEWAY_PROVIDER_* environment variables",
},
{
name: "BedrockTypeHappyPath",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=bedrock",
"CODER_AIBRIDGE_PROVIDER_0_NAME=bedrock-prod",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-east-1",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY=AKID",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRET=secret",
},
expected: []codersdk.AIProviderConfig{
{
Type: string(database.AiProviderTypeBedrock),
Name: "bedrock-prod",
BedrockRegion: "us-east-1",
BedrockAccessKeys: []string{"AKID"},
BedrockAccessKeySecrets: []string{"secret"},
},
},
},
{
name: "BedrockTypeWithoutBedrockFields",
env: []string{"CODER_AIBRIDGE_PROVIDER_0_TYPE=bedrock", "CODER_AIBRIDGE_PROVIDER_0_NAME=bedrock-prod"},
errContains: "requires BEDROCK_* fields to be configured",
},
{
name: "BedrockTypeRejectsAPIKeys",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=bedrock",
"CODER_AIBRIDGE_PROVIDER_0_NAME=bedrock-prod",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-east-1",
"CODER_AIBRIDGE_PROVIDER_0_KEY=sk-should-fail",
},
errContains: "KEY/KEYS are not supported for TYPE",
},
{
name: "BedrockKeysTooMany",
env: []string{
@@ -544,8 +581,9 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
const dumpDir = "/tmp/coder-aibridge-dumps"
tests := []struct {
name string
row database.AIProvider
name string
row database.AIProvider
expectedType string
}{
{
name: "OpenAI",
@@ -554,6 +592,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
Name: "openai",
BaseUrl: "https://api.openai.com/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Anthropic",
@@ -562,6 +601,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
Name: "anthropic",
BaseUrl: "https://api.anthropic.com/",
},
expectedType: aibridge.ProviderAnthropic,
},
{
name: "Copilot",
@@ -570,6 +610,68 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
Name: "copilot",
BaseUrl: "https://api.githubcopilot.com/",
},
expectedType: aibridge.ProviderCopilot,
},
{
name: "Azure",
row: database.AIProvider{
Type: database.AiProviderTypeAzure,
Name: "azure",
BaseUrl: "https://example.openai.azure.com/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Google",
row: database.AIProvider{
Type: database.AiProviderTypeGoogle,
Name: "google",
BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "OpenAICompat",
row: database.AIProvider{
Type: database.AiProviderTypeOpenaiCompat,
Name: "openai-compat",
BaseUrl: "https://compat.example.com/v1/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "OpenRouter",
row: database.AIProvider{
Type: database.AiProviderTypeOpenrouter,
Name: "openrouter",
BaseUrl: "https://openrouter.ai/api/v1/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Vercel",
row: database.AIProvider{
Type: database.AiProviderTypeVercel,
Name: "vercel",
BaseUrl: "https://api.v0.dev/v1/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Bedrock",
row: database.AIProvider{
Type: database.AiProviderTypeBedrock,
Name: "bedrock",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
Settings: mustMarshalSettings(codersdk.AIProviderSettings{
Bedrock: &codersdk.AIProviderBedrockSettings{
Region: "us-east-1",
AccessKey: ptr.Ref("AKID"),
AccessKeySecret: ptr.Ref("secret"),
},
}),
},
expectedType: aibridge.ProviderAnthropic,
},
}
@@ -583,6 +685,29 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
})
require.NoError(t, err)
assert.Equal(t, dumpDir, provider.APIDumpDir())
assert.Equal(t, tt.expectedType, provider.Type())
})
}
}
func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) {
t.Parallel()
_, err := buildAIProviderFromRow(database.AIProvider{
Type: database.AiProviderTypeBedrock,
Name: "bedrock-no-settings",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
}, nil, codersdk.AIBridgeConfig{
AllowBYOK: serpent.Bool(true),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "bedrock provider has no bedrock credentials configured")
}
func mustMarshalSettings(s codersdk.AIProviderSettings) sql.NullString {
data, err := json.Marshal(s)
if err != nil {
panic(err)
}
return sql.NullString{String: string(data), Valid: true}
}