mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: open staged self-host models to all accounts (#922)
A staged self-host model is granted to every account without a per-account grant, making the admin import the only v1 gate. Wrap the access resolver so every gate opens together; the admin catalog reports self-host state weights-first. Per-account grants remain as a dormant seam.
This commit is contained in:
@@ -282,13 +282,15 @@ func (gw *GatewayConfig) bedrockRecorder() gateway_bedrock.Recorder {
|
||||
return gateway_bedrock.NoopRecorder
|
||||
}
|
||||
|
||||
// bedrockAccessResolver returns gw.BedrockAccess as an AccessResolver, or the
|
||||
// bedrockAccessResolver returns gw.BedrockAccess wrapped so a staged
|
||||
// self-host model is granted to every account (import is the grant), or the
|
||||
// deny-all fallback when no grant store is configured. Model access is
|
||||
// deny-by-default, so an unconfigured gateway advertises and serves no models
|
||||
// rather than all of them.
|
||||
// rather than all of them; staged-open behaviour requires the access
|
||||
// subsystem to be present, so the fallback stays unwrapped.
|
||||
func (gw *GatewayConfig) bedrockAccessResolver() gateway_bedrock.AccessResolver {
|
||||
if gw.BedrockAccess != nil {
|
||||
return gw.BedrockAccess
|
||||
return gateway_bedrock.NewStagedOpenAccessResolver(gw.BedrockAccess)
|
||||
}
|
||||
return gateway_bedrock.DenyAllAccessResolver
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package gateway_bedrock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// stagedOpenAccessResolver wraps an inner AccessResolver so a staged
|
||||
// self-host model is granted to every account without an explicit grant:
|
||||
// import is the grant. An explicit inner grant (and the GlobalAccountID
|
||||
// system bypass it already honors) still wins, so the per-account seam stays
|
||||
// live for a later isolated/paid tier. Provider-tier models are unaffected —
|
||||
// they still need an explicit inner grant.
|
||||
type stagedOpenAccessResolver struct {
|
||||
inner AccessResolver
|
||||
}
|
||||
|
||||
var _ AccessResolver = (*stagedOpenAccessResolver)(nil)
|
||||
|
||||
// NewStagedOpenAccessResolver wraps inner so a staged self-host model is
|
||||
// granted to every account without an explicit grant, per import-is-the-grant.
|
||||
func NewStagedOpenAccessResolver(inner AccessResolver) AccessResolver {
|
||||
return stagedOpenAccessResolver{inner: inner}
|
||||
}
|
||||
|
||||
// Granted reports true when inner already grants accountID modelID, or when
|
||||
// modelID is a self-host catalog entry with a staged weights snapshot. A
|
||||
// weights-resolve error is a fault, not a false, and propagates rather than
|
||||
// silently denying access.
|
||||
func (r stagedOpenAccessResolver) Granted(ctx context.Context, accountID, modelID string) (bool, error) {
|
||||
granted, err := r.inner.Granted(ctx, accountID, modelID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if granted {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
entry, ok := lookupCatalogEntry(modelID)
|
||||
if !ok || entry.Provider != tierSelfHost {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, staged, err := currentWeightsResolver().Resolve(ctx, modelID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve weights for %s: %w", modelID, err)
|
||||
}
|
||||
return staged, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package gateway_bedrock
|
||||
|
||||
//test:in-package — exercises the unexported stagedOpenAccessResolver and
|
||||
// reuses grantSet/grantAll/failingAccess/stubResolver/stubWeightsResolver/
|
||||
// errWeightsResolver/withWeightsResolver/withProviderCatalogEntry, this
|
||||
// package's existing unexported test doubles.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/bedrock"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// systemBypassInner mimics ModelAccessStore.Granted's GlobalAccountID
|
||||
// shortcut without a real JetStream-backed store: true only for the system
|
||||
// account, false for everyone else regardless of model.
|
||||
type systemBypassInner struct{}
|
||||
|
||||
func (systemBypassInner) Granted(_ context.Context, accountID, _ string) (bool, error) {
|
||||
return accountID == utils.GlobalAccountID, nil
|
||||
}
|
||||
|
||||
func TestStagedOpenAccessResolver_StagedSelfHostGrantedWithoutInnerGrant(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}})
|
||||
r := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
granted, err := r.Granted(context.Background(), "000000000099", selfHostTestModel)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, granted, "a staged self-host model must be granted to any account")
|
||||
}
|
||||
|
||||
func TestStagedOpenAccessResolver_UnstagedSelfHostNotGranted(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}})
|
||||
r := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
granted, err := r.Granted(context.Background(), "000000000099", selfHostTestModel)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, granted, "an unstaged self-host model must not auto-grant")
|
||||
}
|
||||
|
||||
// TestStagedOpenAccessResolver_ProviderTierStillNeedsInnerGrant covers the
|
||||
// tier the plan keeps dormant: staging only auto-opens self-host entries.
|
||||
func TestStagedOpenAccessResolver_ProviderTierStillNeedsInnerGrant(t *testing.T) {
|
||||
withProviderCatalogEntry(t, anthropicTestModel)
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}})
|
||||
r := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
granted, err := r.Granted(context.Background(), "000000000099", anthropicTestModel)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, granted, "a provider-tier model must not be auto-granted by staging")
|
||||
}
|
||||
|
||||
func TestStagedOpenAccessResolver_ExplicitInnerGrantStillWins(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}})
|
||||
r := NewStagedOpenAccessResolver(grantSet{selfHostTestModel: true})
|
||||
|
||||
granted, err := r.Granted(context.Background(), "000000000001", selfHostTestModel)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, granted, "an explicit inner grant must still be honored even when unstaged")
|
||||
}
|
||||
|
||||
func TestStagedOpenAccessResolver_SystemAccountBypassIntact(t *testing.T) {
|
||||
r := NewStagedOpenAccessResolver(systemBypassInner{})
|
||||
|
||||
granted, err := r.Granted(context.Background(), utils.GlobalAccountID, "nonexistent.model-v1:0")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, granted, "the inner resolver's system-account bypass must still win")
|
||||
}
|
||||
|
||||
func TestStagedOpenAccessResolver_WeightsResolveErrorPropagates(t *testing.T) {
|
||||
withWeightsResolver(t, errWeightsResolver{})
|
||||
r := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
_, err := r.Granted(context.Background(), "000000000001", selfHostTestModel)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStagedOpenAccessResolver_InnerErrorPropagates(t *testing.T) {
|
||||
r := NewStagedOpenAccessResolver(failingAccess{})
|
||||
|
||||
_, err := r.Granted(context.Background(), "000000000001", selfHostTestModel)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStagedOpenAccessResolver_UnknownModelNotGranted(t *testing.T) {
|
||||
r := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
granted, err := r.Granted(context.Background(), "000000000001", "nonexistent.model-v1:0")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, granted)
|
||||
}
|
||||
|
||||
// TestStagedOpenAccessResolver_ListFoundationModelsIncludesStagedSelfHostForNonGrantedAccount
|
||||
// is the catalog-layer proof that wrapping bedrockAccessResolver's store
|
||||
// actually reaches ListFoundationModels: a staged self-host model appears for
|
||||
// an account holding no explicit grant.
|
||||
func TestStagedOpenAccessResolver_ListFoundationModelsIncludesStagedSelfHostForNonGrantedAccount(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}})
|
||||
access := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
out, err := ListFoundationModels(context.Background(), "000000000099", stubResolver{ok: map[string]bool{}}, access, &bedrock.ListFoundationModelsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, modelIDs(out), selfHostTestModel)
|
||||
}
|
||||
|
||||
// TestStagedOpenAccessResolver_GetFoundationModelReturnsStagedSelfHostForNonGrantedAccount
|
||||
// covers the describe path the same way.
|
||||
func TestStagedOpenAccessResolver_GetFoundationModelReturnsStagedSelfHostForNonGrantedAccount(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}})
|
||||
access := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
out, err := GetFoundationModel(context.Background(), "000000000099", selfHostTestModel, access)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.ModelDetails)
|
||||
assert.Equal(t, selfHostTestModel, *out.ModelDetails.ModelId)
|
||||
}
|
||||
|
||||
// TestStagedOpenAccessResolver_GrantedCatalogEntryAdmitsStagedSelfHostForNonGrantedAccount
|
||||
// covers the invoke gate every runtime router shares.
|
||||
func TestStagedOpenAccessResolver_GrantedCatalogEntryAdmitsStagedSelfHostForNonGrantedAccount(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}})
|
||||
access := NewStagedOpenAccessResolver(grantSet{})
|
||||
|
||||
entry, err := grantedCatalogEntry(context.Background(), "000000000099", selfHostTestModel, access)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, selfHostTestModel, entry.ModelID)
|
||||
}
|
||||
@@ -64,19 +64,16 @@ func (e catalogEntry) toAdminEntry(availability string) AdminCatalogEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// availabilityReason runs the same three checks tieredCatalog uses to decide
|
||||
// whether to include entry for accountID, but returns which one failed
|
||||
// instead of a bool: an operator-only view is allowed to say why, where the
|
||||
// tenant-facing catalog is not.
|
||||
// availabilityReason runs the same checks tieredCatalog's access.Granted
|
||||
// (via stagedOpenAccessResolver) uses to decide whether to include entry for
|
||||
// accountID, but returns which one failed instead of a bool: an operator-only
|
||||
// view is allowed to say why, where the tenant-facing catalog is not.
|
||||
//
|
||||
// Self-host is judged weights-first, ahead of the grant check: import is the
|
||||
// grant in v1, so a self-host model's only gate is whether it is staged, and
|
||||
// it must never report AvailabilityUngranted. The grant/credential checks
|
||||
// below are reserved for the provider tier, which still requires one.
|
||||
func availabilityReason(ctx context.Context, accountID string, entry catalogEntry, resolver CredentialResolver, access AccessResolver) (string, error) {
|
||||
granted, err := access.Granted(ctx, accountID, entry.ModelID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !granted {
|
||||
return AvailabilityUngranted, nil
|
||||
}
|
||||
|
||||
if entry.Provider == tierSelfHost {
|
||||
_, resolvable, err := currentWeightsResolver().Resolve(ctx, entry.ModelID)
|
||||
if err != nil {
|
||||
@@ -89,6 +86,14 @@ func availabilityReason(ctx context.Context, accountID string, entry catalogEntr
|
||||
return AvailabilityAvailable, nil
|
||||
}
|
||||
|
||||
granted, err := access.Granted(ctx, accountID, entry.ModelID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !granted {
|
||||
return AvailabilityUngranted, nil
|
||||
}
|
||||
|
||||
vendor, ok := strings.CutPrefix(entry.Provider, providerPrefix)
|
||||
if !ok {
|
||||
return AvailabilityNoCredential, nil
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package gateway_bedrock
|
||||
|
||||
//test:in-package — shares unexported test doubles (grantSet, grantAll,
|
||||
// stubResolver, withWeightsResolver, withProviderCatalogEntry) with the rest
|
||||
// of this package's *_test.go files rather than duplicating them behind an
|
||||
// exported surface.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -34,14 +39,42 @@ func TestAdminCatalog_ClaudeEntryAbsent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCatalog_ReasonUngranted covers the ungranted case: the grant check
|
||||
// runs before any weights/credential resolution, so an ungranted account
|
||||
// never learns whether the model would otherwise be servable.
|
||||
// TestAdminCatalog_ReasonUngranted covers the ungranted case for the
|
||||
// provider tier, the only one still gated by an explicit grant: self-host is
|
||||
// judged weights-first and never reports ungranted (see
|
||||
// TestAdminCatalog_SelfHostNeverUngranted).
|
||||
func TestAdminCatalog_ReasonUngranted(t *testing.T) {
|
||||
withProviderCatalogEntry(t, anthropicTestModel)
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}})
|
||||
entries, err := AdminCatalog(context.Background(), "000000000001", stubResolver{ok: map[string]bool{"anthropic": true}}, grantSet{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AvailabilityUngranted, adminEntry(t, entries, anthropicTestModel).Availability)
|
||||
}
|
||||
|
||||
// TestAdminCatalog_SelfHostAvailableWithoutGrant is import-is-the-grant at
|
||||
// the admin layer: a staged self-host model reports available even for an
|
||||
// account holding no explicit grant on it.
|
||||
func TestAdminCatalog_SelfHostAvailableWithoutGrant(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}})
|
||||
entries, err := AdminCatalog(context.Background(), "000000000001", stubResolver{ok: map[string]bool{}}, grantSet{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AvailabilityUngranted, adminEntry(t, entries, selfHostTestModel).Availability)
|
||||
assert.Equal(t, AvailabilityAvailable, adminEntry(t, entries, selfHostTestModel).Availability)
|
||||
}
|
||||
|
||||
// TestAdminCatalog_SelfHostNeverUngranted proves no self-host catalog entry
|
||||
// can report AvailabilityUngranted, staged or not: the reason reserved for
|
||||
// the provider tier must never leak onto a self-host entry.
|
||||
func TestAdminCatalog_SelfHostNeverUngranted(t *testing.T) {
|
||||
withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}})
|
||||
entries, err := AdminCatalog(context.Background(), "000000000001", stubResolver{ok: map[string]bool{}}, grantSet{})
|
||||
require.NoError(t, err)
|
||||
for _, e := range entries {
|
||||
entry, ok := lookupCatalogEntry(e.ModelID)
|
||||
if !ok || entry.Provider != tierSelfHost {
|
||||
continue
|
||||
}
|
||||
assert.NotEqual(t, AvailabilityUngranted, e.Availability, "self-host model %q must never report ungranted", e.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCatalog_ReasonNoWeightsStaged covers a granted self-host model
|
||||
|
||||
Reference in New Issue
Block a user