mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5b10268827
_Disclosure: created with Coder Agents._ When providers are disabled, we should serve a sentinel error so the requesting client (Claude Code, Coder Agents, etc) is informed. Coder Agents can also conditionalize its display to show a helpful error message. --------- Signed-off-by: Danny Kopping <danny@coder.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
724 lines
22 KiB
Go
724 lines
22 KiB
Go
package cli
|
|
|
|
import (
|
|
"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 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}
|
|
}
|