Files
coder/cli/server_aibridge_internal_test.go
Danny Kopping c8555e2163 fix: deprecate ai provider seeding env config (#25854)
Environment variables used to configure AI Gateway providers are now deprecated, and we need to reflect this as such.
2026-06-01 15:15:47 +02:00

788 lines
24 KiB
Go

package cli
import (
"context"
"database/sql"
"encoding/json"
"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/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"
)
func TestReadAIProvidersFromEnv(t *testing.T) {
t.Parallel()
tests := []struct {
name string
env []string
expected []codersdk.AIProviderConfig
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.AIProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: "anthropic-zdr",
Keys: []string{"sk-ant-xxx"},
BaseURL: "https://api.anthropic.com/",
},
},
},
{
name: "SingleProviderAIGatewayPrefix",
env: []string{
"CODER_AI_GATEWAY_PROVIDER_0_TYPE=anthropic",
"CODER_AI_GATEWAY_PROVIDER_0_NAME=anthropic-zdr",
"CODER_AI_GATEWAY_PROVIDER_0_KEY=sk-ant-xxx",
"CODER_AI_GATEWAY_PROVIDER_0_BASE_URL=https://api.anthropic.com/",
},
expected: []codersdk.AIProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: "anthropic-zdr",
Keys: []string{"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.AIProviderConfig{
{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.AIProviderConfig{
{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.AIProviderConfig{
{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.AIProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: "anthropic-bedrock",
BedrockRegion: "us-west-2",
BedrockAccessKeys: []string{"AKID"},
BedrockAccessKeySecrets: []string{"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.AIProviderConfig{
{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.AIProviderConfig{
{Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Keys: []string{"sk-xxx"}},
},
},
{
// KEYS is a plural alias for KEY.
name: "PluralKeysAlias",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-ant-xxx",
},
expected: []codersdk.AIProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: aibridge.ProviderAnthropic,
Keys: []string{"sk-ant-xxx"},
},
},
},
{
// BEDROCK_ACCESS_KEYS and BEDROCK_ACCESS_KEY_SECRETS are
// plural aliases for their singular counterparts.
name: "PluralBedrockAliases",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=secret",
},
expected: []codersdk.AIProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: aibridge.ProviderAnthropic,
BedrockAccessKeys: []string{"AKID"},
BedrockAccessKeySecrets: []string{"secret"},
},
},
},
{
// An Anthropic provider can't use both a bearer token
// (KEYS) and Bedrock (BEDROCK_*); they're mutually
// exclusive authentication modes.
name: "AnthropicKeysAndBedrockConflict",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-ant-xxx",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-east-1",
},
errContains: "KEY/KEYS and BEDROCK_* fields are mutually exclusive",
},
{
name: "ConflictKeyAndKeys",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_KEY=sk-single",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-multi",
},
errContains: "KEY and KEYS are mutually exclusive",
},
{
name: "ConflictBedrockAccessKeyAndKeys",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY=AKID1",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID2",
},
errContains: "BEDROCK_ACCESS_KEY and BEDROCK_ACCESS_KEYS are mutually exclusive",
},
{
name: "ConflictBedrockSecretAndSecrets",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRET=s1",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=s2",
},
errContains: "BEDROCK_ACCESS_KEY_SECRET and BEDROCK_ACCESS_KEY_SECRETS are mutually exclusive",
},
{
name: "CopilotRejectsKey",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=copilot",
"CODER_AIBRIDGE_PROVIDER_0_KEY=sk-xxx",
},
errContains: "KEY/KEYS are not supported for TYPE",
},
{
name: "CopilotRejectsKeys",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=copilot",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-a,sk-b",
},
errContains: "KEY/KEYS are not supported for TYPE",
},
{
name: "MultipleKeysCommaSeparated",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-a,sk-b,sk-c",
},
expected: []codersdk.AIProviderConfig{
{Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Keys: []string{"sk-a", "sk-b", "sk-c"}},
},
},
{
name: "KeysWhitespaceTrimmed",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_KEYS= sk-a , sk-b ",
},
expected: []codersdk.AIProviderConfig{
{Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Keys: []string{"sk-a", "sk-b"}},
},
},
{
name: "KeysEmptyAfterTrim",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-a,,sk-b",
},
errContains: "key at index 1 is empty",
},
{
name: "KeysDuplicate",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-a,sk-b,sk-a",
},
errContains: "duplicate key at index 2",
},
{
name: "KeysTooMany",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-1,sk-2,sk-3,sk-4,sk-5,sk-6",
},
errContains: "too many keys (6), maximum is 5",
},
{
name: "BedrockMultipleKeys",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-west-2",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID1,AKID2",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=secret1,secret2",
},
expected: []codersdk.AIProviderConfig{
{
Type: aibridge.ProviderAnthropic,
Name: aibridge.ProviderAnthropic,
BedrockRegion: "us-west-2",
BedrockAccessKeys: []string{"AKID1", "AKID2"},
BedrockAccessKeySecrets: []string{"secret1", "secret2"},
},
},
},
{
name: "BedrockKeyCountMismatch",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID1,AKID2",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRET=secret1",
},
errContains: "BEDROCK_ACCESS_KEYS count (2) must match BEDROCK_ACCESS_KEY_SECRETS count (1)",
},
{
name: "MixedPrefixesAreNotAllowed",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-1",
"CODER_AI_GATEWAY_PROVIDER_0_TYPE=anthropic",
"CODER_AI_GATEWAY_PROVIDER_0_NAME=anthropic-2",
},
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{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=anthropic",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID1,AKID2,AKID3,AKID4,AKID5,AKID6",
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=s1,s2,s3,s4,s5,s6",
},
errContains: "too many keys (6), maximum is 5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
providers, err := ReadAIProvidersFromEnv(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.AIProviderConfig
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.AIProviderConfig{
Type: aibridge.ProviderOpenAI,
Name: fmt.Sprintf("p%d", i),
Keys: []string{fmt.Sprintf("sk-%d", i)},
})
}
providers, err := ReadAIProvidersFromEnv(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 TYYYPPOO instead of TYPE should not prevent startup;
// the function logs a warning and continues.
tests := []struct {
name string
env []string
expected []codersdk.AIProviderConfig
expectedWarnings []string
}{
{
name: "AIGatewayPrefix",
env: []string{
"CODER_AI_GATEWAY_PROVIDER_0_TYPE=openai",
"CODER_AI_GATEWAY_PROVIDER_0_Name=test",
"CODER_AI_GATEWAY_PROVIDER_0_TYYYPPOO=openai",
},
expected: []codersdk.AIProviderConfig{
{Type: "openai", Name: "test"},
},
expectedWarnings: []string{"CODER_AI_GATEWAY_PROVIDER_0_TYYYPPOO"},
},
{
name: "AIBridgePrefix",
env: []string{
"CODER_AIBRIDGE_PROVIDER_0_TYPE=openai",
"CODER_AIBRIDGE_PROVIDER_0_Name=test",
"CODER_AIBRIDGE_PROVIDER_0_TYYYPPOO=openai",
},
expected: []codersdk.AIProviderConfig{
{Type: "openai", Name: "test"},
},
expectedWarnings: []string{"CODER_AIBRIDGE_PROVIDER_0_TYYYPPOO"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
providers, err := ReadAIProvidersFromEnv(sink.Logger(), tt.env)
require.NoError(t, err)
require.Equal(t, tt.expected, providers)
warnings := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ignoring unknown AI provider field (check for typos)"
})
require.Len(t, warnings, len(tt.expectedWarnings))
for i, want := range tt.expectedWarnings {
require.Len(t, warnings[i].Fields, 1)
assert.Equal(t, want, warnings[i].Fields[0].Value)
}
})
}
})
}
func TestValidateLegacyAIBridgeConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg codersdk.AIBridgeConfig
errContains string
}{
{
name: "BareAnthropicKey",
cfg: codersdk.AIBridgeConfig{
LegacyAnthropic: codersdk.AIBridgeAnthropicConfig{Key: "sk-ant"},
},
},
{
name: "BareBedrockRegion",
cfg: codersdk.AIBridgeConfig{
LegacyBedrock: codersdk.AIBridgeBedrockConfig{Region: "us-east-1"},
},
},
{
name: "BedrockCredentialsOnly",
cfg: codersdk.AIBridgeConfig{
LegacyBedrock: codersdk.AIBridgeBedrockConfig{
AccessKey: "AKIA",
AccessKeySecret: "secret",
},
},
},
{
name: "AnthropicKeyAndBedrockConflict",
cfg: codersdk.AIBridgeConfig{
LegacyAnthropic: codersdk.AIBridgeAnthropicConfig{Key: "sk-ant"},
LegacyBedrock: codersdk.AIBridgeBedrockConfig{
Region: "us-east-1",
AccessKey: "AKIA",
AccessKeySecret: "secret",
},
},
errContains: "CODER_AIBRIDGE_ANTHROPIC_KEY and CODER_AIBRIDGE_BEDROCK_* are mutually exclusive",
},
{
name: "AnthropicKeyWithBedrockModelDefaultsIsFine",
cfg: codersdk.AIBridgeConfig{
LegacyAnthropic: codersdk.AIBridgeAnthropicConfig{Key: "sk-ant"},
// Model defaults shouldn't trip the conflict; they're
// always populated in a real deployment.
LegacyBedrock: codersdk.AIBridgeBedrockConfig{
Model: "anthropic.claude-3-5-sonnet",
SmallFastModel: "anthropic.claude-3-5-haiku",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateLegacyAIBridgeConfig(tt.cfg)
if tt.errContains == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
})
}
}
func TestWarnIfAIProvidersConfiguredFromEnv(t *testing.T) {
t.Parallel()
t.Run("NoProviders", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, nil)
require.Empty(t, sink.Entries())
})
t.Run("EmptyPrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), "", []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
require.Empty(t, sink.Entries())
})
t.Run("AIGatewayPrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
entries := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup"
})
require.Len(t, entries, 1)
require.Len(t, entries[0].Fields, 2)
assertFieldValue(t, entries[0].Fields, "env_prefix", aiGatewayProviderEnvPrefix)
assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.")
})
t.Run("AIBridgePrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiBridgeProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
entries := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup"
})
require.Len(t, entries, 1)
require.Len(t, entries[0].Fields, 2)
assertFieldValue(t, entries[0].Fields, "env_prefix", aiBridgeProviderEnvPrefix)
assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.")
})
}
func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
t.Parallel()
const dumpDir = "/tmp/coder-aibridge-dumps"
tests := []struct {
name string
row database.AIProvider
expectedType string
}{
{
name: "OpenAI",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenai,
Name: "openai",
BaseUrl: "https://api.openai.com/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Anthropic",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeAnthropic,
Name: "anthropic",
BaseUrl: "https://api.anthropic.com/",
},
expectedType: aibridge.ProviderAnthropic,
},
{
name: "Copilot",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeCopilot,
Name: "copilot",
BaseUrl: "https://api.githubcopilot.com/",
},
expectedType: aibridge.ProviderCopilot,
},
{
name: "Azure",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeAzure,
Name: "azure",
BaseUrl: "https://example.openai.azure.com/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Google",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeGoogle,
Name: "google",
BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "OpenAICompat",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenaiCompat,
Name: "openai-compat",
BaseUrl: "https://compat.example.com/v1/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "OpenRouter",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenrouter,
Name: "openrouter",
BaseUrl: "https://openrouter.ai/api/v1/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Vercel",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeVercel,
Name: "vercel",
BaseUrl: "https://api.v0.dev/v1/",
},
expectedType: aibridge.ProviderOpenAI,
},
{
name: "Bedrock",
row: database.AIProvider{
Enabled: true,
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,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
provider, err := buildAIProviderFromRow(tt.row, nil, codersdk.AIBridgeConfig{
AllowBYOK: serpent.Bool(true),
APIDumpDir: serpent.String(dumpDir),
})
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{
Enabled: true,
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}
}
func assertFieldValue(t *testing.T, fields slog.Map, name string, expected interface{}) {
t.Helper()
for _, f := range fields {
if f.Name == name {
assert.Equal(t, expected, f.Value)
return
}
}
t.Errorf("field %q not found", name)
}