Files
coder/codersdk/aiproviders_test.go
Seth ShelnuttandCian Johnston faa9815cd6 feat: configure Claude Platform for AWS on Anthropic providers (#29049)
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>
2026-09-29 09:20:31 +01:00

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
}