[proxy,management] Serve Bedrock model discovery from the control plane (#7250)

[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.
This commit is contained in:
Maycon Santos
2026-08-23 20:29:10 +02:00
committed by GitHub
parent 5e88d3f87a
commit f03853867b
14 changed files with 730 additions and 72 deletions
+47 -25
View File
@@ -97,6 +97,18 @@ func isPlainJSONListing(resp *http.Response) bool {
return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json")
}
// listingEnvelopes maps a listing's wrapper key to the field naming the model
// id inside it. Vendors did not converge on one shape: OpenAI's is what
// Anthropic adopted, while Bedrock returns inference-profile summaries under a
// key of its own. A body matching none of these is forwarded untouched.
var listingEnvelopes = []struct {
key string
idField string
}{
{"data", "id"},
{"inferenceProfileSummaries", "inferenceProfileId"},
}
// filterListingBody returns the listing with unauthorised entries removed.
// ok is false when the body is not a listing shape, in which case the
// caller must forward the original bytes.
@@ -105,38 +117,41 @@ func filterListingBody(body []byte, permitted map[string]struct{}) ([]byte, bool
if err := json.Unmarshal(body, &doc); err != nil {
return nil, false
}
raw, present := doc["data"]
if !present {
return nil, false
}
var entries []map[string]json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, false
}
kept := make([]map[string]json.RawMessage, 0, len(entries))
for _, entry := range entries {
if entryPermitted(entry, permitted) {
kept = append(kept, entry)
for _, envelope := range listingEnvelopes {
raw, present := doc[envelope.key]
if !present {
continue
}
var entries []map[string]json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, false
}
}
encoded, err := json.Marshal(kept)
if err != nil {
return nil, false
kept := make([]map[string]json.RawMessage, 0, len(entries))
for _, entry := range entries {
if entryPermitted(entry, envelope.idField, permitted) {
kept = append(kept, entry)
}
}
encoded, err := json.Marshal(kept)
if err != nil {
return nil, false
}
doc[envelope.key] = encoded
out, err := json.Marshal(doc)
if err != nil {
return nil, false
}
return out, true
}
doc["data"] = encoded
out, err := json.Marshal(doc)
if err != nil {
return nil, false
}
return out, true
return nil, false
}
// entryPermitted reports whether a listing entry names a model the policy
// authorises, trying every form the same model is written in.
func entryPermitted(entry map[string]json.RawMessage, permitted map[string]struct{}) bool {
raw, ok := entry["id"]
func entryPermitted(entry map[string]json.RawMessage, idField string, permitted map[string]struct{}) bool {
raw, ok := entry[idField]
if !ok {
return false
}
@@ -184,6 +199,13 @@ func modelIDForms(id string) []string {
return nil
}
forms := []string{id, sharedllm.NormalizeAnthropicModel(id)}
// A Bedrock listing returns region-prefixed, version-suffixed profile ids
// ("eu.anthropic.claude-haiku-4-5-20251001-v1:0") while the record may
// register the catalog key. Stripping to the key is a no-op for ids that
// carry neither, so this costs nothing on the other surfaces.
if bedrock := sharedllm.NormalizeBedrockModel(id); bedrock != id {
forms = append(forms, bedrock)
}
if slash := strings.Index(id, "/"); slash > 0 {
if _, ok := gatewayNamespaces[id[:slash]]; ok {
tail := id[slash+1:]
@@ -233,3 +233,40 @@ func TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders(t *testing.T) {
assert.Equal(t, strconv.Itoa(len(body)), resp.Header.Get("Content-Length"),
"the Content-Length header must not be rewritten to the truncated prefix")
}
// TestFilterBedrockInferenceProfiles covers the second listing envelope. AWS
// returns inference-profile summaries under a key of its own with an id field
// of its own, so a filter that only knew OpenAI's shape forwarded a Bedrock
// listing whole — offering every profile in the account regardless of policy.
func TestFilterBedrockInferenceProfiles(t *testing.T) {
body := []byte(`{"inferenceProfileSummaries":[
{"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0","status":"ACTIVE"},
{"inferenceProfileId":"eu.anthropic.claude-sonnet-4-6","status":"ACTIVE"},
{"inferenceProfileId":"global.cohere.embed-v4:0","status":"ACTIVE"}
]}`)
// The permitted set holds what the record registers. Here that is the
// catalog key, while the vendor answers with region-prefixed wire ids —
// the two must still line up.
permitted := map[string]struct{}{"anthropic.claude-haiku-4-5": {}}
out, ok := filterListingBody(body, permitted)
require.True(t, ok, "a Bedrock listing must be recognised as filterable")
var doc struct {
Summaries []struct {
ID string `json:"inferenceProfileId"`
} `json:"inferenceProfileSummaries"`
}
require.NoError(t, json.Unmarshal(out, &doc))
require.Len(t, doc.Summaries, 1)
assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", doc.Summaries[0].ID)
}
// TestFilterLeavesUnknownEnvelopesAlone keeps the best-effort contract: a body
// the filter cannot parse must reach the client exactly as the upstream sent
// it, rather than being rewritten into something shorter and wrong.
func TestFilterLeavesUnknownEnvelopesAlone(t *testing.T) {
_, ok := filterListingBody([]byte(`{"models":[{"name":"something"}]}`), map[string]struct{}{})
assert.False(t, ok)
}