Ctrl K
ComunidadGuías…

codersdk/aiproviders_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}