mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
This PR adds configuration plumbing for Claude Platform on AWS ## Summary Makes Claude Platform for AWS configurable as a settings variant of `type=anthropic`, discriminated by `_type: "claude_platform_aws"`. Settings contain only `region` and `workspace_id`, plus the schema version. No new provider type or database enum is introduced. The API persists and projects those settings to the gateway. Provider keys can be added or removed independently: allowed BYOK takes precedence, followed by stored keys, then ambient SigV4 when neither key source applies. Model-availability hints and keyless provider construction reflect that ambient capability. Rejected or exhausted keys do not fall back to IAM. Removes Claude Platform-specific AWS identity fields, secret redaction/merge behavior, external-ID handling, and mode/key consistency checks. Bedrock configuration remains intact. Environment-based provider seeding was removed upstream and is not restored. The Claude Platform form and Experimental label remain follow-up work in #29050. ## Authentication limitation Each Claude Platform provider uses either the gateway's ambient AWS credentials when no provider keys are stored, or its stored API-key pool. In either case, a client credential takes precedence when BYOK is enabled. Rejected BYOK or an exhausted provider key pool does not fall back to ambient IAM. There is no per-provider AWS identity selection: keyless providers on the same gateway share its ambient AWS credential chain and cannot configure separate AWS access keys or assumed roles. Each provider still has its own Claude Platform workspace ID and separate provider attribution in Coder's usage/cost records; this does not imply separate AWS billing accounts. ## Validation - Race tests passed for SDK, daemon projection, db2sdk, chatprovider, and targeted CLI/API configuration tests. - Installed pre-commit hook passed, including generation, frontend/Go lint, and build. - Tests cover settings round trips, validation, adding/removing the last key without settings changes, keyless construction, model availability, and Bedrock regressions. Generated by Coder Agents on behalf of @johnstcn and @Shelnutt2 Co-authored-by: Cian Johnston <cian@coder.com>
797 lines
24 KiB
Go
797 lines
24 KiB
Go
package codersdk_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/aibridge/config"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
func TestAIProviderSettings_Marshal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("EmptyEmitsNull", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := json.Marshal(codersdk.AIProviderSettings{})
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `null`, string(got))
|
|
})
|
|
|
|
t.Run("BedrockEmitsDiscriminator", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := json.Marshal(codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{
|
|
Region: "us-east-1",
|
|
Model: "anthropic.claude-3-5-sonnet",
|
|
SmallFastModel: "anthropic.claude-3-5-haiku",
|
|
AccessKey: new("AKIA-test"), //nolint:gosec // fixture
|
|
AccessKeySecret: new("secret"),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{
|
|
"_type": "bedrock",
|
|
"_version": 1,
|
|
"region": "us-east-1",
|
|
"model": "anthropic.claude-3-5-sonnet",
|
|
"small_fast_model": "anthropic.claude-3-5-haiku",
|
|
"access_key": "AKIA-test",
|
|
"access_key_secret": "secret"
|
|
}`, string(got))
|
|
})
|
|
|
|
t.Run("BedrockOmitsEmptyFields", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := json.Marshal(codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{
|
|
"_type": "bedrock",
|
|
"_version": 1,
|
|
"region": "us-east-1"
|
|
}`, string(got))
|
|
})
|
|
}
|
|
|
|
func TestAIProviderSettings_Unmarshal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("EmptyInputZeroes", func(t *testing.T) {
|
|
t.Parallel()
|
|
// encoding/json never invokes UnmarshalJSON with an empty
|
|
// payload, but the method must still tolerate it for callers
|
|
// (e.g. row decoders) that hand it raw column bytes.
|
|
var s codersdk.AIProviderSettings
|
|
require.NoError(t, s.UnmarshalJSON(nil))
|
|
require.True(t, s.IsZero())
|
|
require.NoError(t, s.UnmarshalJSON([]byte("")))
|
|
require.True(t, s.IsZero())
|
|
})
|
|
|
|
t.Run("NullZeroes", func(t *testing.T) {
|
|
t.Parallel()
|
|
var s codersdk.AIProviderSettings
|
|
require.NoError(t, json.Unmarshal([]byte(`null`), &s))
|
|
require.True(t, s.IsZero())
|
|
})
|
|
|
|
t.Run("BedrockSupportedVersion", func(t *testing.T) {
|
|
t.Parallel()
|
|
var s codersdk.AIProviderSettings
|
|
require.NoError(t, json.Unmarshal([]byte(`{
|
|
"_type": "bedrock",
|
|
"_version": 1,
|
|
"region": "us-east-1",
|
|
"model": "anthropic.claude-3-5-sonnet"
|
|
}`), &s))
|
|
require.NotNil(t, s.Bedrock)
|
|
require.Equal(t, "us-east-1", s.Bedrock.Region)
|
|
require.Equal(t, "anthropic.claude-3-5-sonnet", s.Bedrock.Model)
|
|
})
|
|
|
|
t.Run("MissingTypeDiscriminator", func(t *testing.T) {
|
|
t.Parallel()
|
|
var s codersdk.AIProviderSettings
|
|
err := json.Unmarshal([]byte(`{"_version":1,"region":"us-east-1"}`), &s)
|
|
require.ErrorContains(t, err, "missing _type discriminator")
|
|
})
|
|
|
|
t.Run("UnsupportedVersion", func(t *testing.T) {
|
|
t.Parallel()
|
|
var s codersdk.AIProviderSettings
|
|
err := json.Unmarshal([]byte(`{"_type":"bedrock","_version":99}`), &s)
|
|
require.ErrorContains(t, err, `unsupported "bedrock" settings version 99`)
|
|
require.ErrorContains(t, err, "expected 1")
|
|
})
|
|
|
|
t.Run("UnknownType", func(t *testing.T) {
|
|
t.Parallel()
|
|
var s codersdk.AIProviderSettings
|
|
err := json.Unmarshal([]byte(`{"_type":"copilot","_version":1}`), &s)
|
|
require.ErrorContains(t, err, `unknown settings type "copilot"`)
|
|
})
|
|
|
|
t.Run("MalformedHeader", func(t *testing.T) {
|
|
t.Parallel()
|
|
// _type must be a string; passing a number triggers the
|
|
// header decode path before any discriminator routing.
|
|
var s codersdk.AIProviderSettings
|
|
err := json.Unmarshal([]byte(`{"_type": 1}`), &s)
|
|
require.ErrorContains(t, err, "decode settings header")
|
|
require.ErrorContains(t, err, "_type")
|
|
})
|
|
|
|
t.Run("ResetsBetweenCalls", func(t *testing.T) {
|
|
t.Parallel()
|
|
// A non-zero value passed to Unmarshal should be reset when
|
|
// the payload decodes to null, so callers can reuse the
|
|
// variable without leaking stale state.
|
|
s := codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"},
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(`null`), &s))
|
|
require.True(t, s.IsZero())
|
|
})
|
|
}
|
|
|
|
func TestAIProviderSettings_Roundtrip(t *testing.T) {
|
|
t.Parallel()
|
|
orig := codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{
|
|
Region: "us-west-2",
|
|
Model: "anthropic.claude-sonnet-4-5",
|
|
SmallFastModel: "anthropic.claude-haiku-4-5",
|
|
AccessKey: new("AKIA-roundtrip"), //nolint:gosec // fixture
|
|
AccessKeySecret: new("secret-roundtrip"),
|
|
},
|
|
}
|
|
encoded, err := json.Marshal(orig)
|
|
require.NoError(t, err)
|
|
// Sanity: discriminator is part of the on-wire shape.
|
|
require.True(t, strings.Contains(string(encoded), `"_type":"bedrock"`))
|
|
|
|
var got codersdk.AIProviderSettings
|
|
require.NoError(t, json.Unmarshal(encoded, &got))
|
|
require.Equal(t, orig, got)
|
|
}
|
|
|
|
func TestAIProviderRequest_ValidateAPIKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
keys []string
|
|
wantCreateField string
|
|
wantUpdateField string
|
|
wantDetail string
|
|
}{
|
|
{name: "Omitted"},
|
|
{name: "Empty", keys: []string{}},
|
|
{name: "One", keys: []string{"key-1"}},
|
|
{name: "Five", keys: []string{"key-1", "key-2", "key-3", "key-4", "key-5"}},
|
|
{
|
|
name: "Six",
|
|
keys: []string{"key-1", "key-2", "key-3", "key-4", "key-5", "key-6"},
|
|
wantCreateField: "api_keys",
|
|
wantUpdateField: "api_keys",
|
|
wantDetail: "api_keys must contain at most 5 keys",
|
|
},
|
|
{
|
|
name: "Duplicate",
|
|
keys: []string{"key-1", "key-2", "key-1"},
|
|
wantCreateField: "api_keys[2]",
|
|
wantUpdateField: "api_keys[2].api_key",
|
|
wantDetail: "duplicate key already provided at api_keys[0]",
|
|
},
|
|
{
|
|
name: "DuplicateAfterDifferentKey",
|
|
keys: []string{"key-1", "key-2", "key-2"},
|
|
wantCreateField: "api_keys[2]",
|
|
wantUpdateField: "api_keys[2].api_key",
|
|
wantDetail: "duplicate key already provided at api_keys[1]",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeOpenAI,
|
|
Name: "keys",
|
|
BaseURL: "https://api.openai.com/v1",
|
|
APIKeys: tc.keys,
|
|
}
|
|
createValidations := create.Validate()
|
|
if tc.wantCreateField == "" {
|
|
require.Empty(t, createValidations)
|
|
} else {
|
|
require.Len(t, createValidations, 1)
|
|
require.Equal(t, tc.wantCreateField, createValidations[0].Field)
|
|
require.Equal(t, tc.wantDetail, createValidations[0].Detail)
|
|
for _, key := range tc.keys {
|
|
require.NotContains(t, createValidations[0].Detail, key)
|
|
}
|
|
}
|
|
|
|
muts := make([]codersdk.AIProviderKeyMutation, 0, len(tc.keys))
|
|
for _, key := range tc.keys {
|
|
muts = append(muts, codersdk.AIProviderKeyMutation{APIKey: new(key)})
|
|
}
|
|
update := codersdk.UpdateAIProviderRequest{APIKeys: &muts}
|
|
updateValidations := update.Validate()
|
|
if tc.wantUpdateField == "" {
|
|
require.Empty(t, updateValidations)
|
|
} else {
|
|
require.Len(t, updateValidations, 1)
|
|
require.Equal(t, tc.wantUpdateField, updateValidations[0].Field)
|
|
require.Equal(t, tc.wantDetail, updateValidations[0].Detail)
|
|
for _, key := range tc.keys {
|
|
require.NotContains(t, updateValidations[0].Detail, key)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateAIProviderKeyUniqueness(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
seen := map[string]int{"key-1": 1}
|
|
require.Empty(t, codersdk.ValidateAIProviderKeyUniqueness("key-2", "api_keys[2]", seen))
|
|
require.Equal(t, map[string]int{"key-1": 1}, seen)
|
|
|
|
require.Equal(t, []codersdk.ValidationError{{
|
|
Field: "api_keys[3]",
|
|
Detail: "duplicate key already provided at api_keys[1]",
|
|
}}, codersdk.ValidateAIProviderKeyUniqueness("key-1", "api_keys[3]", seen))
|
|
require.Equal(t, map[string]int{"key-1": 1}, seen)
|
|
}
|
|
|
|
func TestAIProviderRequest_ValidateBedrockCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
key *string
|
|
secret *string
|
|
wantField string
|
|
wantDetail string
|
|
}{
|
|
{name: "Omitted"},
|
|
{name: "Empty", key: new(""), secret: new("")},
|
|
{name: "EmptyKey", key: new("")},
|
|
{name: "EmptySecret", secret: new("")},
|
|
{name: "Paired", key: new("key"), secret: new("longer-secret")},
|
|
{
|
|
name: "MissingKey",
|
|
secret: new("secret"),
|
|
wantField: "settings.access_key",
|
|
wantDetail: "access_key_secret is set, but access_key is missing or empty",
|
|
},
|
|
{
|
|
name: "MissingSecret",
|
|
key: new("key"),
|
|
wantField: "settings.access_key_secret",
|
|
wantDetail: "access_key is set, but access_key_secret is missing or empty",
|
|
},
|
|
{
|
|
name: "ClearedKey",
|
|
key: new(""),
|
|
secret: new("secret"),
|
|
wantField: "settings.access_key",
|
|
wantDetail: "access_key_secret is set, but access_key is missing or empty",
|
|
},
|
|
{
|
|
name: "ClearedSecret",
|
|
key: new("key"),
|
|
secret: new(""),
|
|
wantField: "settings.access_key_secret",
|
|
wantDetail: "access_key is set, but access_key_secret is missing or empty",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
settings := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{
|
|
Region: "us-east-1",
|
|
Model: "model",
|
|
SmallFastModel: "small-model",
|
|
AccessKey: tc.key,
|
|
AccessKeySecret: tc.secret,
|
|
}}
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeBedrock, Name: "bedrock",
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", Settings: settings,
|
|
}
|
|
var want []codersdk.ValidationError
|
|
if tc.wantField != "" {
|
|
want = []codersdk.ValidationError{{
|
|
Field: tc.wantField, Detail: tc.wantDetail,
|
|
}}
|
|
}
|
|
require.Equal(t, want, create.Validate())
|
|
require.Equal(t, want, settings.Bedrock.ValidateCredentials())
|
|
|
|
update := codersdk.UpdateAIProviderRequest{Settings: &settings}
|
|
if tc.key == nil || tc.secret == nil {
|
|
// Omitted credentials are merged from storage by the API.
|
|
require.Empty(t, update.Validate())
|
|
} else {
|
|
require.Equal(t, want, update.Validate())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAIProviderRequest_ValidateRoleARN(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
roleARN string
|
|
wantErr bool
|
|
}{
|
|
{name: "empty is allowed", roleARN: "", wantErr: false},
|
|
{name: "standard role arn", roleARN: "arn:aws:iam::743809215448:role/bedrock-role", wantErr: false},
|
|
{name: "govcloud partition", roleARN: "arn:aws-us-gov:iam::123456789012:role/bedrock-role", wantErr: false},
|
|
{name: "china partition", roleARN: "arn:aws-cn:iam::123456789012:role/bedrock-role", wantErr: false},
|
|
{name: "role path", roleARN: "arn:aws:iam::123456789012:role/team/bedrock-role", wantErr: false},
|
|
{name: "not an arn", roleARN: "bedrock-role", wantErr: true},
|
|
{name: "wrong resource type", roleARN: "arn:aws:iam::123456789012:user/dave", wantErr: true},
|
|
{name: "wrong service", roleARN: "arn:aws:s3:::my-bucket", wantErr: true},
|
|
{name: "truncated arn", roleARN: "arn:aws:iam::123456789012", wantErr: true},
|
|
}
|
|
|
|
hasRoleARNError := func(vs []codersdk.ValidationError) bool {
|
|
for _, v := range vs {
|
|
if v.Field == "settings.role_arn" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
settings := codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{
|
|
Region: "us-east-1",
|
|
RoleARN: tc.roleARN,
|
|
},
|
|
}
|
|
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeBedrock,
|
|
Name: "bedrock",
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
Settings: settings,
|
|
}
|
|
require.Equal(t, tc.wantErr, hasRoleARNError(create.Validate()))
|
|
|
|
update := codersdk.UpdateAIProviderRequest{Settings: &settings}
|
|
require.Equal(t, tc.wantErr, hasRoleARNError(update.Validate()))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAIProviderRequest_ValidateBedrockProtocol(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
protocol codersdk.AIProviderBedrockProtocol
|
|
wantErr bool
|
|
}{
|
|
{name: "empty is allowed", protocol: "", wantErr: false},
|
|
{name: "invoke-model", protocol: codersdk.AIProviderBedrockProtocolInvokeModel, wantErr: false},
|
|
{name: "mantle", protocol: codersdk.AIProviderBedrockProtocolMantle, wantErr: false},
|
|
{name: "typo", protocol: "mnatle", wantErr: true},
|
|
{name: "unknown", protocol: "http", wantErr: true},
|
|
}
|
|
|
|
hasProtocolError := func(vs []codersdk.ValidationError) bool {
|
|
for _, v := range vs {
|
|
if v.Field == "settings.protocol" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
settings := codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{
|
|
Region: "us-east-1",
|
|
Protocol: tc.protocol,
|
|
},
|
|
}
|
|
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeBedrock,
|
|
Name: "bedrock",
|
|
BaseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
|
|
Settings: settings,
|
|
}
|
|
require.Equal(t, tc.wantErr, hasProtocolError(create.Validate()))
|
|
|
|
update := codersdk.UpdateAIProviderRequest{Settings: &settings}
|
|
require.Equal(t, tc.wantErr, hasProtocolError(update.Validate()))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAIProviderRequest_ValidateBedrockMantle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
hasFieldError := func(vs []codersdk.ValidationError, field string) bool {
|
|
for _, v := range vs {
|
|
if v.Field == field {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
t.Run("MantleRequiresRegion", func(t *testing.T) {
|
|
t.Parallel()
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeBedrock,
|
|
Name: "bedrock",
|
|
BaseURL: "https://bedrock-mantle.us-east-1.api.aws",
|
|
Settings: codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{
|
|
Protocol: codersdk.AIProviderBedrockProtocolMantle,
|
|
},
|
|
},
|
|
}
|
|
require.True(t, hasFieldError(create.Validate(), "settings.region"))
|
|
|
|
create.Settings.Bedrock.Region = "us-east-1"
|
|
require.False(t, hasFieldError(create.Validate(), "settings.region"))
|
|
})
|
|
|
|
t.Run("MantleRequiresRegionOnUpdate", func(t *testing.T) {
|
|
t.Parallel()
|
|
settings := codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{
|
|
Protocol: codersdk.AIProviderBedrockProtocolMantle,
|
|
},
|
|
}
|
|
update := codersdk.UpdateAIProviderRequest{Settings: &settings}
|
|
require.True(t, hasFieldError(update.Validate(), "settings.region"))
|
|
|
|
settings.Bedrock.Region = "us-east-1"
|
|
require.False(t, hasFieldError(update.Validate(), "settings.region"))
|
|
})
|
|
|
|
t.Run("InvokeModelDoesNotRequireRegionField", func(t *testing.T) {
|
|
t.Parallel()
|
|
// The mantle-specific region check must not fire for the invoke-model
|
|
// protocol, whether it is set explicitly or left empty (existing rows).
|
|
for _, protocol := range []codersdk.AIProviderBedrockProtocol{"", codersdk.AIProviderBedrockProtocolInvokeModel} {
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeBedrock,
|
|
Name: "bedrock",
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
Settings: codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{Protocol: protocol},
|
|
},
|
|
}
|
|
require.False(t, hasFieldError(create.Validate(), "settings.region"))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAIProviderRequest_ValidationInSync keeps API-level validation
|
|
// (CreateAIProviderRequest.Validate) and runtime-level validation
|
|
// (config.AWSBedrock.Validate) in sync.
|
|
func TestAIProviderRequest_ValidationInSync(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
model = "anthropic.claude-sonnet-4-5"
|
|
smallFastModel = "anthropic.claude-haiku-4-5"
|
|
)
|
|
|
|
cases := []struct {
|
|
name string
|
|
baseURL string
|
|
bedrock codersdk.AIProviderBedrockSettings
|
|
isValid bool
|
|
}{
|
|
{
|
|
name: "InvokeModelRegionOnly",
|
|
baseURL: "https://bedrock.us-east-2.amazonaws.com",
|
|
bedrock: codersdk.AIProviderBedrockSettings{Region: "us-east-2"},
|
|
isValid: false,
|
|
},
|
|
{
|
|
name: "InvokeModelMissingSmallFastModel",
|
|
baseURL: "https://bedrock.us-east-2.amazonaws.com",
|
|
bedrock: codersdk.AIProviderBedrockSettings{
|
|
Region: "us-east-2",
|
|
Model: model,
|
|
},
|
|
isValid: false,
|
|
},
|
|
{
|
|
name: "InvokeModelComplete",
|
|
baseURL: "https://bedrock.us-east-2.amazonaws.com",
|
|
bedrock: codersdk.AIProviderBedrockSettings{
|
|
Region: "us-east-2",
|
|
Model: model,
|
|
SmallFastModel: smallFastModel,
|
|
},
|
|
isValid: true,
|
|
},
|
|
{
|
|
// Mantle forwards the client's model unchanged, so it needs neither.
|
|
name: "MantleWithoutModels",
|
|
baseURL: "https://bedrock-mantle.us-east-2.api.aws/anthropic",
|
|
bedrock: codersdk.AIProviderBedrockSettings{
|
|
Region: "us-east-2",
|
|
Protocol: codersdk.AIProviderBedrockProtocolMantle,
|
|
},
|
|
isValid: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Mirror the settings-to-runtime conversion that cli/aibridged.go
|
|
// performs when it builds providers from the database.
|
|
runtimeCfg := config.AWSBedrock{
|
|
BaseURL: tc.baseURL,
|
|
Region: tc.bedrock.Region,
|
|
Model: tc.bedrock.Model,
|
|
SmallFastModel: tc.bedrock.SmallFastModel,
|
|
Protocol: config.BedrockProtocol(tc.bedrock.ResolvedProtocol()),
|
|
}
|
|
require.Equal(t, tc.isValid, runtimeCfg.Validate() == nil,
|
|
"config.AWSBedrock.Validate disagrees with the expected verdict")
|
|
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeBedrock,
|
|
Name: "bedrock",
|
|
BaseURL: tc.baseURL,
|
|
Settings: codersdk.AIProviderSettings{Bedrock: &tc.bedrock},
|
|
}
|
|
require.Equal(t, tc.isValid, len(create.Validate()) == 0,
|
|
"the API disagrees with the expected verdict")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAIProviderSettings_ClaudePlatformAWS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("MarshalEmitsDiscriminator", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := json.Marshal(codersdk.AIProviderSettings{
|
|
ClaudePlatformAWS: &codersdk.AIProviderClaudePlatformAWSSettings{
|
|
Region: "us-east-1",
|
|
WorkspaceID: "wrkspc_123",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{
|
|
"_type": "claude_platform_aws",
|
|
"_version": 1,
|
|
"region": "us-east-1",
|
|
"workspace_id": "wrkspc_123"
|
|
}`, string(got))
|
|
})
|
|
|
|
t.Run("Roundtrip", func(t *testing.T) {
|
|
t.Parallel()
|
|
in := codersdk.AIProviderSettings{
|
|
ClaudePlatformAWS: &codersdk.AIProviderClaudePlatformAWSSettings{
|
|
Region: "eu-central-1",
|
|
WorkspaceID: "wrkspc_roundtrip",
|
|
},
|
|
}
|
|
encoded, err := json.Marshal(in)
|
|
require.NoError(t, err)
|
|
var out codersdk.AIProviderSettings
|
|
require.NoError(t, json.Unmarshal(encoded, &out))
|
|
require.Nil(t, out.Bedrock)
|
|
require.Equal(t, in.ClaudePlatformAWS, out.ClaudePlatformAWS)
|
|
})
|
|
|
|
t.Run("UnmarshalRejectsUnsupportedVersion", func(t *testing.T) {
|
|
t.Parallel()
|
|
var out codersdk.AIProviderSettings
|
|
err := json.Unmarshal([]byte(`{"_type":"claude_platform_aws","_version":2}`), &out)
|
|
require.ErrorContains(t, err, "unsupported")
|
|
})
|
|
|
|
// A settings blob encodes one authentication method. Silently marshaling
|
|
// only the first populated variant would persist a provider that
|
|
// authenticates differently from what the caller asked for.
|
|
t.Run("MarshalRejectsMultipleVariants", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := json.Marshal(codersdk.AIProviderSettings{
|
|
Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"},
|
|
ClaudePlatformAWS: &codersdk.AIProviderClaudePlatformAWSSettings{
|
|
Region: "us-east-1",
|
|
WorkspaceID: "wrkspc_123",
|
|
},
|
|
})
|
|
require.ErrorContains(t, err, "exactly one authentication method")
|
|
})
|
|
|
|
t.Run("IsZero", func(t *testing.T) {
|
|
t.Parallel()
|
|
require.True(t, codersdk.AIProviderSettings{}.IsZero())
|
|
require.False(t, codersdk.AIProviderSettings{
|
|
ClaudePlatformAWS: &codersdk.AIProviderClaudePlatformAWSSettings{},
|
|
}.IsZero())
|
|
})
|
|
}
|
|
|
|
func TestAIProviderRequest_ValidateClaudePlatformAWS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
newSettings := func() *codersdk.AIProviderClaudePlatformAWSSettings {
|
|
return &codersdk.AIProviderClaudePlatformAWSSettings{
|
|
Region: "us-east-1",
|
|
WorkspaceID: "wrkspc_123",
|
|
}
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
providerTyp codersdk.AIProviderType
|
|
apiKeys []string
|
|
mutate func(*codersdk.AIProviderClaudePlatformAWSSettings)
|
|
errField string
|
|
}{
|
|
{
|
|
name: "AmbientCredentials",
|
|
providerTyp: codersdk.AIProviderTypeAnthropic,
|
|
},
|
|
{
|
|
name: "APIKeyValid",
|
|
providerTyp: codersdk.AIProviderTypeAnthropic,
|
|
apiKeys: []string{"sk-workspace-key"},
|
|
},
|
|
{
|
|
// Claude Platform is an authentication method on Anthropic, never
|
|
// a provider type of its own.
|
|
name: "RejectedOnBedrockType",
|
|
providerTyp: codersdk.AIProviderTypeBedrock,
|
|
errField: "settings",
|
|
},
|
|
{
|
|
name: "RegionRequired",
|
|
providerTyp: codersdk.AIProviderTypeAnthropic,
|
|
mutate: func(s *codersdk.AIProviderClaudePlatformAWSSettings) { s.Region = "" },
|
|
errField: "settings.region",
|
|
},
|
|
{
|
|
name: "WorkspaceIDRequired",
|
|
providerTyp: codersdk.AIProviderTypeAnthropic,
|
|
mutate: func(s *codersdk.AIProviderClaudePlatformAWSSettings) { s.WorkspaceID = "" },
|
|
errField: "settings.workspace_id",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
settings := newSettings()
|
|
if tc.mutate != nil {
|
|
tc.mutate(settings)
|
|
}
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: tc.providerTyp,
|
|
Name: "anthropic-claude-platform",
|
|
BaseURL: "https://aws-external-anthropic.us-east-1.api.aws",
|
|
APIKeys: tc.apiKeys,
|
|
Settings: codersdk.AIProviderSettings{ClaudePlatformAWS: settings},
|
|
}
|
|
validations := create.Validate()
|
|
if tc.errField == "" {
|
|
require.Empty(t, validations)
|
|
return
|
|
}
|
|
require.True(t, hasAIProviderFieldError(validations, tc.errField),
|
|
"expected an error on %q, got %v", tc.errField, validations)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAIProviderRequest_ClaudePlatformValidationInSync keeps API-level
|
|
// validation (CreateAIProviderRequest.Validate) and runtime-level validation
|
|
// (config.AWSClaudePlatform.Validate) in agreement, so a provider the API
|
|
// accepts is one the gateway can actually build.
|
|
func TestAIProviderRequest_ClaudePlatformValidationInSync(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
settings codersdk.AIProviderClaudePlatformAWSSettings
|
|
apiKeys []string
|
|
isValid bool
|
|
}{
|
|
{
|
|
name: "IAMAmbientCredentials",
|
|
settings: codersdk.AIProviderClaudePlatformAWSSettings{
|
|
Region: "us-east-1",
|
|
WorkspaceID: "wrkspc_123",
|
|
},
|
|
isValid: true,
|
|
},
|
|
{
|
|
name: "APIKey",
|
|
settings: codersdk.AIProviderClaudePlatformAWSSettings{
|
|
Region: "us-east-1",
|
|
WorkspaceID: "wrkspc_123",
|
|
},
|
|
apiKeys: []string{"sk-workspace-key"},
|
|
isValid: true,
|
|
},
|
|
{
|
|
name: "MissingWorkspaceID",
|
|
settings: codersdk.AIProviderClaudePlatformAWSSettings{
|
|
Region: "us-east-1",
|
|
},
|
|
isValid: false,
|
|
},
|
|
{
|
|
name: "MissingRegion",
|
|
settings: codersdk.AIProviderClaudePlatformAWSSettings{
|
|
WorkspaceID: "wrkspc_123",
|
|
},
|
|
isValid: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Mirror the settings-to-runtime conversion cli/aibridged.go
|
|
// performs when it builds providers from the database.
|
|
runtimeCfg := config.AWSClaudePlatform{
|
|
Region: tc.settings.Region,
|
|
WorkspaceID: tc.settings.WorkspaceID,
|
|
}
|
|
require.Equal(t, tc.isValid, runtimeCfg.Validate() == nil,
|
|
"config.AWSClaudePlatform.Validate disagrees with the expected verdict")
|
|
|
|
create := codersdk.CreateAIProviderRequest{
|
|
Type: codersdk.AIProviderTypeAnthropic,
|
|
Name: "anthropic",
|
|
BaseURL: "https://aws-external-anthropic.us-east-1.api.aws",
|
|
APIKeys: tc.apiKeys,
|
|
Settings: codersdk.AIProviderSettings{ClaudePlatformAWS: &tc.settings},
|
|
}
|
|
require.Equal(t, tc.isValid, len(create.Validate()) == 0,
|
|
"the API disagrees with the expected verdict")
|
|
})
|
|
}
|
|
}
|
|
|
|
// hasAIProviderFieldError reports whether any validation error targets the
|
|
// named field.
|
|
func hasAIProviderFieldError(vs []codersdk.ValidationError, field string) bool {
|
|
for _, v := range vs {
|
|
if v.Field == field {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|