From cb83f9b19335e9e2d6db8ca4559ac9cac3ab5a26 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:22:33 +1000 Subject: [PATCH] fix(bedrock): GetFoundationModel answers an unknown model with ValidationException (#1104) --- spinifex/gateway/bedrock/catalog.go | 20 +++++++----------- spinifex/gateway/bedrock/catalog_test.go | 26 ++++++++++++++---------- spinifex/gateway/bedrock_request_test.go | 10 ++++----- tests/e2e/bedrock/bedrock_test.go | 10 ++++----- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/spinifex/gateway/bedrock/catalog.go b/spinifex/gateway/bedrock/catalog.go index 3bc785bd1..cea06728e 100644 --- a/spinifex/gateway/bedrock/catalog.go +++ b/spinifex/gateway/bedrock/catalog.go @@ -463,8 +463,7 @@ func LookupServingSpec(modelID string) (spec ServingSpec, found, selfHost bool) // An unknown model and an ungranted one are deliberately distinguishable here // (ResourceNotFoundException vs AccessDeniedException) because the caller has // already been told the model exists by its own catalog listing; the describe -// path collapses both to ResourceNotFoundException instead, where that is not -// true. +// path collapses both to ValidationException instead, where that is not true. func grantedCatalogEntry(ctx context.Context, accountID, modelID string, access AccessResolver) (catalogEntry, error) { entry, ok := lookupCatalogEntry(modelID) if !ok { @@ -496,18 +495,13 @@ func ListFoundationModels(ctx context.Context, accountID string, resolver Creden return &bedrock.ListFoundationModelsOutput{ModelSummaries: summaries}, nil } -// GetFoundationModel looks up a single model by exact modelId, gated by the -// caller's grant. An ungranted model, and a self-host model with no resolvable -// weights snapshot, are both reported as ResourceNotFoundException rather than -// AccessDeniedException so describe agrees with list: a model the account -// cannot see does not exist as far as this API is concerned, and the error does -// not confirm the model's existence to an account probing for it. A weights -// resolve error is an internal fault, not a not-found verdict, and is surfaced -// (and logged) as such. +// GetFoundationModel describes one model. Unknown, ungranted and unservable +// models all fail with AWS's ValidationException so describe agrees with list +// and never confirms a hidden model exists; a weights resolve error is a fault. func GetFoundationModel(ctx context.Context, accountID string, modelID string, access AccessResolver) (*bedrock.GetFoundationModelOutput, error) { entry, ok := lookupCatalogEntry(modelID) if !ok { - return nil, errors.New(awserrors.ErrorResourceNotFoundException) + return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The provided model identifier is invalid.") } // Grant first: an ungranted account must not learn whether the model is // servable, only that it is not there. @@ -516,7 +510,7 @@ func GetFoundationModel(ctx context.Context, accountID string, modelID string, a return nil, err } if !granted { - return nil, errors.New(awserrors.ErrorResourceNotFoundException) + return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The provided model identifier is invalid.") } if entry.Provider == tierSelfHost { _, resolvable, err := currentWeightsResolver().Resolve(ctx, entry.ModelID) @@ -525,7 +519,7 @@ func GetFoundationModel(ctx context.Context, accountID string, modelID string, a return nil, fmt.Errorf("resolve weights for %s: %w", entry.ModelID, err) } if !resolvable { - return nil, errors.New(awserrors.ErrorResourceNotFoundException) + return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The provided model identifier is invalid.") } } return &bedrock.GetFoundationModelOutput{ModelDetails: entry.toDetails()}, nil diff --git a/spinifex/gateway/bedrock/catalog_test.go b/spinifex/gateway/bedrock/catalog_test.go index b7456d42c..64013038d 100644 --- a/spinifex/gateway/bedrock/catalog_test.go +++ b/spinifex/gateway/bedrock/catalog_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/aws/aws-sdk-go/service/bedrock" + "github.com/mulgadc/spinifex/spinifex/awserrors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -125,11 +126,11 @@ func TestGetFoundationModel_KnownModelWithResolvableWeights(t *testing.T) { assert.Equal(t, selfHostTestModel, *out.ModelDetails.ModelId) } -func TestGetFoundationModel_SelfHostWithUnresolvableWeightsReturnsNotFound(t *testing.T) { +func TestGetFoundationModel_SelfHostWithUnresolvableWeightsMatchesUnknown(t *testing.T) { withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}}) _, err := GetFoundationModel(context.Background(), "000000000001", selfHostTestModel, grantAll{}) require.Error(t, err) - assert.Equal(t, "ResourceNotFoundException", err.Error()) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationException)) } // TestListFoundationModels_GrantDoesNotOverrideCredentialTier keeps the two @@ -163,26 +164,29 @@ func TestGetFoundationModel_KnownGrantedModel(t *testing.T) { assert.Equal(t, selfHostTestModel, *out.ModelDetails.ModelId) } -func TestGetFoundationModel_UnknownModelReturnsNotFound(t *testing.T) { +func TestGetFoundationModel_UnknownModelReturnsValidation(t *testing.T) { _, err := GetFoundationModel(context.Background(), "000000000001", "does-not-exist", grantAll{}) require.Error(t, err) - assert.Equal(t, "ResourceNotFoundException", err.Error()) + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorValidationException, code) + assert.Equal(t, "The provided model identifier is invalid.", message) } -// TestGetFoundationModel_UngrantedModelReturnsNotFound pins describe to the -// same answer as list: an ungranted model is reported as absent rather than -// forbidden, so the error cannot be used to confirm the model exists. -func TestGetFoundationModel_UngrantedModelReturnsNotFound(t *testing.T) { +// TestGetFoundationModel_UngrantedModelMatchesUnknown pins describe to the +// same answer as list: an ungranted model gets the unknown-model error rather +// than a denial, so the error cannot be used to confirm the model exists. +func TestGetFoundationModel_UngrantedModelMatchesUnknown(t *testing.T) { _, err := GetFoundationModel(context.Background(), "000000000001", selfHostTestModel, grantSet{}) require.Error(t, err) - assert.Equal(t, "ResourceNotFoundException", err.Error()) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationException)) } -func TestGetFoundationModel_WeightsResolveErrorIsNotResourceNotFound(t *testing.T) { +func TestGetFoundationModel_WeightsResolveErrorIsNotValidation(t *testing.T) { withWeightsResolver(t, errWeightsResolver{}) _, err := GetFoundationModel(context.Background(), "000000000001", selfHostTestModel, grantAll{}) require.Error(t, err) - assert.NotEqual(t, "ResourceNotFoundException", err.Error()) + assert.False(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationException)) } func TestListFoundationModels_WeightsResolveErrorPropagates(t *testing.T) { diff --git a/spinifex/gateway/bedrock_request_test.go b/spinifex/gateway/bedrock_request_test.go index 43cddde81..6090124ae 100644 --- a/spinifex/gateway/bedrock_request_test.go +++ b/spinifex/gateway/bedrock_request_test.go @@ -126,12 +126,12 @@ func TestBedrockRequest_DenyByDefault(t *testing.T) { assert.Empty(t, out.ModelSummaries) }) - t.Run("describe reports not found", func(t *testing.T) { + t.Run("describe reports it as unknown", func(t *testing.T) { req := bedrockRequestWithAccount(http.MethodGet, "/foundation-models/"+bedrockTestLlamaModelID, "") w := httptest.NewRecorder() err := gw.Bedrock_Request(w, req) require.Error(t, err) - assert.Equal(t, awserrors.ErrorResourceNotFoundException, err.Error()) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationException)) }) // Every runtime verb must refuse, not just the one that happens to be @@ -187,7 +187,7 @@ func TestBedrockRequest_GetFoundationModel(t *testing.T) { assert.Equal(t, bedrockTestLlamaModelID, *out.ModelDetails.ModelId) } -func TestBedrockRequest_GetFoundationModel_NotFound(t *testing.T) { +func TestBedrockRequest_GetFoundationModel_UnknownModel(t *testing.T) { ts := newVLLMStub(t) gw := newBedrockRequestGateway(t, ts.URL) @@ -195,7 +195,7 @@ func TestBedrockRequest_GetFoundationModel_NotFound(t *testing.T) { w := httptest.NewRecorder() err := gw.Bedrock_Request(w, req) require.Error(t, err) - assert.Equal(t, awserrors.ErrorResourceNotFoundException, err.Error()) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationException)) } func TestBedrockRequest_UnknownRouteReturnsInvalidAction(t *testing.T) { @@ -249,7 +249,7 @@ func TestBedrockRuntimeRequest_MalformedBodyReturnsValidationException(t *testin w := httptest.NewRecorder() err := gw.BedrockRuntime_Request(w, req) require.Error(t, err) - assert.Equal(t, awserrors.ErrorValidationException, err.Error()) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationException)) } func TestBedrockRuntimeRequest_InvokeModel(t *testing.T) { diff --git a/tests/e2e/bedrock/bedrock_test.go b/tests/e2e/bedrock/bedrock_test.go index a8f3ee157..55d3070d9 100644 --- a/tests/e2e/bedrock/bedrock_test.go +++ b/tests/e2e/bedrock/bedrock_test.go @@ -22,17 +22,17 @@ const ( modelBogus = "does.not-exist-v1:0" ) -// TestGetFoundationModel asserts ResourceNotFoundException for an unknown -// model ID. v1 ships no provider-tier entry that resolves unconditionally +// TestGetFoundationModel asserts ValidationException for an unknown model ID, +// as AWS returns. v1 ships no provider-tier entry that resolves unconditionally // (modelAnthropic is not in the catalog at all — it returns the same -// ResourceNotFoundException as a genuinely unknown ID), so the positive, +// ValidationException as a genuinely unknown ID), so the positive, // known-model path is covered separately by TestGetFoundationModelSelfHost, // which is gated on a real weights snapshot being staged. func TestGetFoundationModel(t *testing.T) { f := requireBedrockFixture(t) t.Run("unknown model", func(t *testing.T) { - harness.ExpectError(t, "ResourceNotFoundException", func() error { + harness.ExpectError(t, "ValidationException", func() error { _, e := f.AWS.Bedrock.GetFoundationModel(&bedrock.GetFoundationModelInput{ ModelIdentifier: aws.String(modelBogus), }) @@ -41,7 +41,7 @@ func TestGetFoundationModel(t *testing.T) { }) t.Run("provider-tier model ID is absent from v1", func(t *testing.T) { - harness.ExpectError(t, "ResourceNotFoundException", func() error { + harness.ExpectError(t, "ValidationException", func() error { _, e := f.AWS.Bedrock.GetFoundationModel(&bedrock.GetFoundationModelInput{ ModelIdentifier: aws.String(modelAnthropic), })