Two gaps a review surfaced.
Moving a record from one catalog provider to another changed neither field the
check looked at, so an unchanged credential started being offered to a
different vendor, under a different auth header, with nothing asking whether it
was accepted there.
And each host lookup built its own eight-second budget from a background
context, so a slow resolver could spend one before the request spent another,
and a caller that gave up was still waiting. Bedrock made that three: it now
checks its runtime host as well. One deadline is taken at the top of Fetch and
carried through both lookups and the request.
The create description also had the exemption backwards, reading as though an
upstream the check cannot reach is stored unverified. Unreachable blocks; only
what cannot be checked at all is exempt.
Bedrock lists from the control plane and infers on the runtime host, so a
successful listing says nothing about the URL on the record. An upstream
matching no catalog template left no region to derive, which skipped the check
entirely: a record pointed at a host that does not exist saved clean, and every
request it later served went nowhere.
Entries that declare a listing host of their own now have their configured
upstream resolved on its own account. A host that will not resolve blocks the
save; one that resolves privately does not, since Bedrock behind a proxy is a
supported configuration and stays the unverifiable case it already was.
The dial-time guard reported every non-public address as ErrPrivateHost, which
the credential check reads as "this upstream cannot be reached from here, so
save it unchecked". checkPublicHost has already cleared the target by the time
anything is dialled, so an address refused at the socket is never the
operator's upstream — it is a rebinding attempt, or an HTTP proxy the
management server egresses through. A deployment behind such a proxy would
install this feature and have it silently do nothing on every provider.
It now reports an ordinary failure, which classifies as unreachable and blocks.
Only the resolve-stage check still means "cannot be checked", and that one
knows it is looking at the operator's own host.
This is also why the three fixtures below passed locally and failed in CI: a
sandbox that egresses through a loopback proxy skipped the check entirely,
while CI reached the real api.openai.com and had the dummy key refused. They
want a provider row rather than a working vendor, so they move to a private
address and no longer depend on where a hostname resolves or whether the runner
has egress.
The SSRF guard resolves the host before any request is built, so a name that
does not resolve fails there rather than at the transport, and that error
reached the caller unclassified — an operator with a typo in the hostname was
told the provider could not be checked rather than that the url could not be
reached. It is the commonest way for an upstream to be wrong.
The live suite is what caught it: the unit tests construct the transport
errors directly and so never went through the guard.
The management fixture moves to a private upstream in the same change. It
wants a provider row to hang a policy off, not a working vendor, and it was
pointing a dummy key at the real api.openai.com — which the credential check
now correctly refuses. A private address is left unchecked whether or not the
run has vendor keys, and covers that path while it is there.
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.
[proxy,management] Serve Bedrock model discovery from the control plane
A Bedrock provider could never answer a model-discovery request. The router
sent GET /inference-profiles to the record's upstream, which has to be
bedrock-runtime.<region> for InvokeModel to work, and that host does not
implement the operation. ListInferenceProfiles is a control-plane operation on
bedrock.<region>.amazonaws.com, and one provider record carries one upstream,
so the two hosts genuinely differ.
The route now carries a discovery host, taken from the catalog's declaration
with the region read back out of the configured upstream, and the listing — and
only the listing — goes there. Inference is untouched. A proxied or self-hosted
Bedrock endpoint gets no discovery host at all rather than a guessed one, since
inventing a host would send the operator's credential somewhere they never
configured.
Two things had to follow for the listing to be usable once it arrives. The
response filter only understood OpenAI's {"data":[{"id":…}]}, so a Bedrock
listing fell through it untouched, offering every profile in the account
whatever the policy said. And discoverableModels intersected by exact string,
so a record registering the raw profile id while a guardrail names the catalog
key intersected to nothing — bounding a working provider's listing down to
empty.
Normalisation is the third. The geography in front of a cross-region profile
was matched against a hardcoded list of four, so every profile issued under jp,
au, ca, sa or us-gov carried its prefix into the pricing key, matched no
catalog entry and metered at zero. It is now recognised by either the geography
or the vendor that follows it, so an id has to be new on both axes at once to
slip through — a live eu-central-1 listing returned "global.xai.grok-4.6" days
after the vendor list was first written.
[management] Offer a provider's live model list in the config form
Adds POST /api/agent-network/catalog/providers/models, which asks a vendor
which models an operator's own credential can actually reach, so the provider
form can offer a live list instead of only the compiled-in catalog. The catalog
goes stale, and it cannot see an account: which OpenAI models an org is
entitled to, which Bedrock inference profiles an account and region hold, which
Vertex models a project has enabled.
The endpoints, auth headers and response shapes come from probing the live APIs
(#7244); each vendor invented its own envelope and none can be guessed from the
request. Bedrock shaped the design: its listing lives on the control plane
while inference must go to the runtime host, so Discovery carries its own host
rather than reusing the record's upstream, and profile ids are taken verbatim
because the region prefix is what AWS requires at invoke time.
A caller supplies either the key they are typing or the id of a saved record
whose stored credential is reused — never both, since accepting both would run
an arbitrary credential under the identity of a record the caller may only be
permitted to read. Gated on Create rather than Read, because this spends the
operator's credential against a third party.
Management has not made outbound calls on an operator's behalf before and it
holds a credential for every provider, so every resolved address must be public
— covering loopback, RFC1918, the cloud metadata address and NetBird's own
100.64/10 range — and redirects are not followed, since a redirect moves the
request to a host the check never saw.
The vendor is authoritative for the id; the catalog stays authoritative for
pricing. A discovered model the shipped table cannot price returns
pricing_known: false so the operator must set rates rather than being
registered at a silent zero.