[management] Check a provider's url and credential before saving it

The provider form accepted anything and found out later. A typo in the
upstream, a key pasted a character short, an AWS access key in a field that
wants a Bedrock API key — all saved cleanly, then surfaced minutes later as a
failed request or an empty model picker, with nothing pointing back at the
record that caused it.

CreateProvider now spends the credential once against the vendor's own model
listing, and UpdateProvider does the same when the upstream or the key
changed — only then, so renames, model rows and price edits neither wait on a
vendor nor fail because one is having a bad day. Both run before the store
write, so a rejected rotation leaves the working key exactly where it was.

The check reuses the discovery Fetch rather than a lighter status probe. It
exercises the path the model picker will take, so a URL answering 200 with a
login page fails here instead of passing a status check and producing an empty
picker later.

Failures that mean "we cannot ask" are not failures: a gateway with no listing
endpoint, a Bedrock record behind a proxy where no control-plane host can be
derived, and a self-hosted endpoint on a private network that the proxy
reaches through the tunnel but management cannot. None of those are evidence
the record is wrong, and refusing them would make this a lockout.

Discovery failures are typed for it. The message an operator sees carries no
status code and never echoes their URL — WriteError lowercases it, and paths
are case-sensitive, so an echoed URL would come back altered and describe
something they did not type. The vendor's status is logged instead.
This commit is contained in:
mlsmaycon
2026-08-23 21:26:06 +00:00
parent f03853867b
commit 4e3578b847
7 changed files with 683 additions and 14 deletions
@@ -126,13 +126,15 @@ type managerImpl struct {
proxyController proxy.Controller
// modelDiscovery queries vendors for the models a credential can reach.
// A field rather than a package call so tests can drive it without
// reaching the network.
// An interface rather than the concrete client because it is now on a
// write path: the credential check runs inside CreateProvider and
// UpdateProvider, so every test that saves a provider would otherwise
// reach a vendor over the network to do it.
//
// One instance serves every request for the process's lifetime, so its
// fields must stay read-only after construction: lazy initialisation
// inside Fetch or httpClient would race across request goroutines.
modelDiscovery *modeldiscovery.Client
modelDiscovery ModelLister
// reconcileCache holds the last set of synthesised proxy mappings
// per account, each paired with the proxy that served it, so a change
@@ -146,6 +148,19 @@ type managerImpl struct {
labelRng *rand.Rand
}
// ManagerOption replaces a manager dependency at construction. Production
// passes none; each option exists for something a test cannot let run for
// real.
type ManagerOption func(*managerImpl)
// WithModelLister replaces the vendor call behind the provider credential
// check. A test that saves a provider needs this — the check runs inside
// CreateProvider and UpdateProvider, so the write path reaches a vendor
// without it.
func WithModelLister(lister ModelLister) ManagerOption {
return func(m *managerImpl) { m.modelDiscovery = lister }
}
// NewManager constructs the persistent Agent Network manager. The
// manager persists provider/policy/guardrail configuration and, on
// every mutation, reconciles the in-memory synthesised reverse-proxy
@@ -156,8 +171,9 @@ func NewManager(
permissionsManager permissions.Manager,
accountManager account.Manager,
proxyController proxy.Controller,
opts ...ManagerOption,
) Manager {
return &managerImpl{
m := &managerImpl{
store: store,
accountManager: accountManager,
permissionsManager: permissionsManager,
@@ -166,6 +182,10 @@ func NewManager(
reconcileCache: make(map[string]map[string]syntheticMapping),
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
@@ -230,6 +250,13 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
return nil, status.Errorf(status.InvalidArgument, "api_key is required when creating an agent network provider")
}
// Before anything is persisted: a record whose upstream or credential does
// not work is rejected here rather than discovered later as a failed
// request with nothing pointing back at it.
if err := m.checkProviderCredential(ctx, provider); err != nil {
return nil, err
}
if provider.ID == "" {
fresh := types.NewProvider(provider.AccountID)
provider.ID = fresh.ID
@@ -269,6 +296,20 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
} else if strings.TrimSpace(provider.APIKey) == "" {
return nil, status.Errorf(status.InvalidArgument, "api_key must be non-blank when rotating an agent network provider")
}
// Only the two fields the vendor would judge are worth a round-trip. This
// same call carries renames, model rows and price edits, and none of those
// should wait on a vendor — or be refused because one is having a bad day.
//
// The comparison runs after the merge above, so an update that changes only
// the URL reads as unchanged on the key and is checked against the stored
// one, which is the only credential the operator has to offer here.
if provider.UpstreamURL != existing.UpstreamURL || provider.APIKey != existing.APIKey {
if err := m.checkProviderCredential(ctx, provider); err != nil {
return nil, err
}
}
// Always preserve the session keypair across updates so existing
// session cookies stay valid. The keys are server-managed and
// never surfaced through the API.